@ingeniomaps/cauce 0.92.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/CHANGELOG.md CHANGED
@@ -14,6 +14,25 @@ desde este repositorio no va, porque el que lee no puede actuar sobre eso. Cuand
14
14
  unas pocas líneas casi siempre es porque cuenta cómo se descubrió el problema o por qué se eligió el
15
15
  diseño — eso vive en el commit y en el código.
16
16
 
17
+ ## [0.93.0] - 2026-09-16
18
+
19
+ ### Corregido
20
+
21
+ - **Declarar un límite ahora lo saca del aviso, en vez de sumarlo.** Si escribiste tus límites como
22
+ viñetas bajo `### Límites` —el camino que 0.92.0 agregó—, `check` los contaba igual como párrafos que
23
+ no llegan a los agentes, y también contaba la frase con la que presentabas la lista. O sea que hacer
24
+ lo correcto **subía** el número: medido sobre un banco, de 4 párrafos avisados pasaba a 6.
25
+
26
+ Ahora una viñeta declarada y la prosa que la presenta no entran en el aviso. Lo que sigue entrando es
27
+ la prosa de afuera del bloque, que es lo único para lo que el aviso existe: descontar el bloque entero
28
+ la habría silenciado, porque a un bloque `### Límites` no lo cierra nada más que el próximo
29
+ encabezado y el del molde se extiende hasta donde escribas el tuyo.
30
+
31
+ - **El aviso te manda al camino declarado y no a imitar una gramática.** Decía que tus párrafos no
32
+ llegan «porque no arrancan con «El runner», «Debe» o «Nunca»». Desde 0.92.0 hay una forma de
33
+ arreglarlo sin imitar nada, y es la que el aviso nombra ahora: sumar el límite como viñeta bajo
34
+ `### Límites`. Los dos caminos siguen valiendo; lo que cambia es cuál se recomienda.
35
+
17
36
  ## [0.92.0] - 2026-09-15
18
37
 
19
38
  ### Agregado
@@ -36,7 +36,6 @@ const BACKLOG = `${P}/BACKLOG.md`
36
36
  const doneFile = (slug) => `${P}/done/${slug}.md`
37
37
  const HUMAN = `${P}/HUMAN_ACTIONS.md`
38
38
  const GATE = `${P}/AWAITING_REVIEW.md`
39
- const ROADMAP = `${P}/roadmap`
40
39
 
41
40
  // Estado de planning tal como lo emite `ops context --json`; ningún modelo parsea BACKLOG ni WIP.
42
41
  // De a pares, y sin regex: una comilla dentro de un literal de regex desincroniza a las dos puertas que
@@ -352,8 +352,6 @@ function prepareProposal(root, agent, now = new Date(), period = '', kind = 'age
352
352
  const reportDir = path.join(target, 'learning', 'reports')
353
353
  const reports = pendingReports(target, sealing)
354
354
  const red = verdictFindings(root, target)
355
- if (!reports.length && !red.findings.length) return { file: '', created: false, reports: 0 }
356
-
357
355
  const reportPaths = reports.map((name) => path.join(reportDir, name))
358
356
  // La misma regla que la rama de recorridos, por el mismo motivo: un documento que no puede decir qué
359
357
  // corregir no cambia ningún contrato y cuesta igual la firma humana que uno que sí. Un informe puede
@@ -0,0 +1,95 @@
1
+ 'use strict'
2
+
3
+ // Qué le falta a la superficie de automatización de una instancia, y nada más. Se mira sin tocar: `check`
4
+ // enumera y devuelve; quien decide qué hacer con esa lista es el CLI.
5
+ //
6
+ // Vive aparte de `index.js` porque no comparte nada con los otros tres verbos. Medido antes de partir:
7
+ // `doctor`, `install` y `uninstall` se apoyan en los mismos ayudantes —`deliveryKey`, `deliveryState`,
8
+ // `removeFile`, `probeBridge`—, y este par no toca ninguno. Lo único que comparte son los imports, que es
9
+ // lo que comparte cualquier archivo del directorio.
10
+ //
11
+ // La partición estaba anotada como deuda desde que el archivo cruzó las 500 líneas con el aviso de
12
+ // sidecar del caso 138, estando en 499.
13
+
14
+ const fs = require('node:fs')
15
+ const path = require('node:path')
16
+ const O = require('../core/ownership')
17
+ const {
18
+ RUNNER_NAMES, packagedAutomation, runnerManifest, runnerPaths, resolveItem, runnerConfig,
19
+ } = require('./runners')
20
+ const { expectedHooks, staleHooks } = require('./hooks')
21
+ const { hasHooks } = require('./config')
22
+
23
+ function check(root) {
24
+ const errors = []
25
+ const hookDir = path.join(root, 'automatization', 'hooks')
26
+ if (!fs.existsSync(path.join(root, 'automatization', 'AGENTS.md'))) {
27
+ errors.push('falta automatization/AGENTS.md')
28
+ }
29
+ for (const name of expectedHooks()) {
30
+ const file = path.join(hookDir, name)
31
+ if (!fs.existsSync(file)) errors.push(`falta automatization/hooks/${name}`)
32
+ else if (!(fs.statSync(file).mode & 0o111)) {
33
+ errors.push(`automatization/hooks/${name} no es ejecutable`)
34
+ }
35
+ }
36
+ // El motor puede venir de la dependencia npm o del propio repositorio, y la cascada la resuelve
37
+ // `packagePath`. Eran tres: la copia vendorizada se retiró en 0.10.0 y esta línea la sobrevivió.
38
+ if (!O.engineAt(root, path.join('hooks', 'run.js'))) {
39
+ errors.push('falta engine/hooks/run.js: corré "npm install" en la raíz del repo ops')
40
+ }
41
+ const workflows = [
42
+ 'autobuild.js',
43
+ 'flow.js',
44
+ path.join('integrations', 'sync.js'),
45
+ path.join('integrations', 'promote.js'),
46
+ ]
47
+ const packaged = packagedAutomation(root)
48
+ for (const name of workflows) {
49
+ if (!packaged || !fs.existsSync(path.join(packaged, 'workflows', name))) {
50
+ errors.push(`falta automatization/workflows/${name}: corré "npm install" en la raíz del repo ops`)
51
+ }
52
+ }
53
+ // Un choque que `upgrade` conservó (caso 110) también queda distinto del paquete, y mandarlo a correr
54
+ // `upgrade` era una vuelta sin salida: lo conservaría otra vez. Se dice qué es y qué hacer.
55
+ const choques = new Set(O.collisions(root))
56
+ for (const { file, edited } of staleHooks(root)) {
57
+ if (choques.has(`automatization/hooks/${file}`)) {
58
+ errors.push(`automatization/hooks/${file}: es tuyo y se llama como uno que trae el paquete, así que el `
59
+ + "del paquete no está instalado; renombrá el tuyo y corré `cauce upgrade`")
60
+ continue
61
+ }
62
+ errors.push(edited
63
+ ? `automatization/hooks/${file}: lo editaste y es del toolkit; agregá un guard propio al lado `
64
+ + 'o descartá tu cambio con `cauce upgrade --force`'
65
+ : `automatization/hooks/${file}: quedó atrás del paquete y ya no protege lo que dice; `
66
+ + 'corré `cauce upgrade` antes de instalar el runner')
67
+ }
68
+ for (const name of RUNNER_NAMES) validateRunnerManifest(root, name, errors)
69
+ return errors
70
+ }
71
+
72
+ function validateRunnerManifest(root, name, errors) {
73
+ try {
74
+ const runner = runnerManifest(root, name)
75
+ if (runner.name !== name || runner.schemaVersion !== 1
76
+ || !runner.config || !runner.capabilities) {
77
+ errors.push(`${name}: manifest incompleto`)
78
+ return
79
+ }
80
+ const paths = runnerPaths(root, name, runner)
81
+ const config = runnerConfig(paths, root)
82
+ if (runner.capabilities.nativeHooks && !hasHooks(config)) {
83
+ errors.push(`${name}: declara hooks nativos pero no los configura`)
84
+ }
85
+ for (const item of [...(runner.instructions || []), ...(runner.artifacts || [])]) {
86
+ const resolved = resolveItem(paths, root, name, item)
87
+ if (!fs.existsSync(resolved.source)) errors.push(`${name}: falta ${item.source}`)
88
+ }
89
+ } catch (error) {
90
+ errors.push(`${name}: configuración inválida (${error.message})`)
91
+ }
92
+ }
93
+
94
+
95
+ module.exports = { check }
@@ -9,89 +9,22 @@ const O = require('../core/ownership')
9
9
  const M = require('../core/manifest')
10
10
  const RL = require('./rules')
11
11
  const {
12
- RUNNER_NAMES, OPS_DIR, OPS_ROOT, packagedAutomation, runnerManifest, installRoot, opsPrefix,
12
+ RUNNER_NAMES, OPS_DIR, OPS_ROOT, runnerManifest, installRoot, opsPrefix,
13
13
  runnerPaths, resolveItem, inline, render, runnerConfig, activated,
14
14
  } = require('./runners')
15
15
  const { roleCatalog, roleSkill, installRoleSkills } = require('./roles')
16
16
  const {
17
- GUARD_NAMES, groupWrappers, expectedHooks, supersededGuards,
18
- legacyGuardWiring, staleHooks, listHooks,
17
+ GUARD_NAMES, groupWrappers, supersededGuards,
18
+ legacyGuardWiring, listHooks,
19
19
  } = require('./hooks')
20
20
  const {
21
- blockStart, mergeConfig, withoutDeliveredHooks, deliveredHookCommands, reportRemoved, includesConfig, hasHooks,
21
+ blockStart, mergeConfig, withoutDeliveredHooks, deliveredHookCommands, reportRemoved, includesConfig,
22
22
  unmergeConfig, isSharedFile, withoutBlock, mergeInstruction, blockUpToDate,
23
23
  } = require('./config')
24
24
 
25
- function check(root) {
26
- const errors = []
27
- const hookDir = path.join(root, 'automatization', 'hooks')
28
- if (!fs.existsSync(path.join(root, 'automatization', 'AGENTS.md'))) {
29
- errors.push('falta automatization/AGENTS.md')
30
- }
31
- for (const name of expectedHooks()) {
32
- const file = path.join(hookDir, name)
33
- if (!fs.existsSync(file)) errors.push(`falta automatization/hooks/${name}`)
34
- else if (!(fs.statSync(file).mode & 0o111)) {
35
- errors.push(`automatization/hooks/${name} no es ejecutable`)
36
- }
37
- }
38
- // El motor puede venir de la dependencia npm o del propio repositorio, y la cascada la resuelve
39
- // `packagePath`. Eran tres: la copia vendorizada se retiró en 0.10.0 y esta línea la sobrevivió.
40
- if (!O.engineAt(root, path.join('hooks', 'run.js'))) {
41
- errors.push('falta engine/hooks/run.js: corré "npm install" en la raíz del repo ops')
42
- }
43
- const workflows = [
44
- 'autobuild.js',
45
- 'flow.js',
46
- path.join('integrations', 'sync.js'),
47
- path.join('integrations', 'promote.js'),
48
- ]
49
- const packaged = packagedAutomation(root)
50
- for (const name of workflows) {
51
- if (!packaged || !fs.existsSync(path.join(packaged, 'workflows', name))) {
52
- errors.push(`falta automatization/workflows/${name}: corré "npm install" en la raíz del repo ops`)
53
- }
54
- }
55
- // Un choque que `upgrade` conservó (caso 110) también queda distinto del paquete, y mandarlo a correr
56
- // `upgrade` era una vuelta sin salida: lo conservaría otra vez. Se dice qué es y qué hacer.
57
- const choques = new Set(O.collisions(root))
58
- for (const { file, edited } of staleHooks(root)) {
59
- if (choques.has(`automatization/hooks/${file}`)) {
60
- errors.push(`automatization/hooks/${file}: es tuyo y se llama como uno que trae el paquete, así que el `
61
- + "del paquete no está instalado; renombrá el tuyo y corré `cauce upgrade`")
62
- continue
63
- }
64
- errors.push(edited
65
- ? `automatization/hooks/${file}: lo editaste y es del toolkit; agregá un guard propio al lado `
66
- + 'o descartá tu cambio con `cauce upgrade --force`'
67
- : `automatization/hooks/${file}: quedó atrás del paquete y ya no protege lo que dice; `
68
- + 'corré `cauce upgrade` antes de instalar el runner')
69
- }
70
- for (const name of RUNNER_NAMES) validateRunnerManifest(root, name, errors)
71
- return errors
72
- }
73
-
74
- function validateRunnerManifest(root, name, errors) {
75
- try {
76
- const runner = runnerManifest(root, name)
77
- if (runner.name !== name || runner.schemaVersion !== 1
78
- || !runner.config || !runner.capabilities) {
79
- errors.push(`${name}: manifest incompleto`)
80
- return
81
- }
82
- const paths = runnerPaths(root, name, runner)
83
- const config = runnerConfig(paths, root)
84
- if (runner.capabilities.nativeHooks && !hasHooks(config)) {
85
- errors.push(`${name}: declara hooks nativos pero no los configura`)
86
- }
87
- for (const item of [...(runner.instructions || []), ...(runner.artifacts || [])]) {
88
- const resolved = resolveItem(paths, root, name, item)
89
- if (!fs.existsSync(resolved.source)) errors.push(`${name}: falta ${item.source}`)
90
- }
91
- } catch (error) {
92
- errors.push(`${name}: configuración inválida (${error.message})`)
93
- }
94
- }
25
+ // Qué le falta a la superficie de automatización, que no comparte ayudantes con los tres verbos que
26
+ // escriben. Se reexporta para que sus consumidores sigan pidiéndoselo a este módulo.
27
+ const { check } = require('./check')
95
28
 
96
29
  // Ejecuta el puente del runner tal como él lo invoca, y desde otra carpeta. Instalado no es lo mismo que
97
30
  // operativo: un bridge que el runner no puede lanzar —porque su ruta es relativa y el cwd es otro, o
@@ -72,22 +72,38 @@ const MARKED = /^###\s+Límites\s*$/m
72
72
  // No filtra comentarios y no hace falta: una viñeta comentada arranca con `<!--`, así que el filtro de
73
73
  // viñetas ya la descarta. Sacar `withoutComments` de acá fue el resultado de una mutación que sobrevivió
74
74
  // —apagarlo no ponía nada en rojo—, que es como se ve una defensa que no defiende de nada.
75
- function declared(raw) {
76
- const out = []
75
+ // Un solo recorrido de los bloques: las viñetas, que son los límites declarados, y la prosa que las
76
+ // presenta. Las dos salen de acá porque dónde empieza y dónde termina un bloque se decide una vez; con
77
+ // dos recorridos, el que delimita para `limits` y el que delimita para `warnings` se despegan y nada
78
+ // falla.
79
+ //
80
+ // El intro se corta en la primera viñeta y no al final del bloque, y eso es lo único que separa este
81
+ // arreglo de un silenciador. A un bloque no lo cierra nada más que el próximo encabezado, así que el del
82
+ // molde se extiende hasta donde alguien escriba el suyo: medido sobre un banco, el primer bloque se
83
+ // llevaba adentro los cuatro párrafos que la persona había agregado al final de la sección. Descontar el
84
+ // bloque entero —que es lo que parecía el arreglo— apagaba justo lo que el aviso existe para encontrar.
85
+ const BULLET = /^\s*[-*]\s+/
86
+ function marked(raw) {
87
+ const bullets = []
88
+ const intros = []
77
89
  let rest = raw
78
90
  for (let start = rest.search(MARKED); start >= 0; start = rest.search(MARKED)) {
79
91
  const after = rest.slice(start).split('\n').slice(1)
80
92
  const end = after.findIndex((line) => /^#{1,3}\s/.test(line))
81
93
  const block = end < 0 ? after : after.slice(0, end)
82
- out.push(...block
83
- .filter((line) => /^\s*[-*]\s+/.test(line))
84
- .map((line) => line.replace(/^\s*[-*]\s+/, '').trim())
94
+ const first = block.findIndex((line) => BULLET.test(line))
95
+ if (first > 0) intros.push(...block.slice(0, first).map((line) => line.trim()).filter(Boolean))
96
+ bullets.push(...block
97
+ .filter((line) => BULLET.test(line))
98
+ .map((line) => line.replace(BULLET, '').trim())
85
99
  .filter(Boolean))
86
100
  rest = (end < 0 ? '' : after.slice(end).join('\n'))
87
101
  }
88
- return out
102
+ return { bullets, intros }
89
103
  }
90
104
 
105
+ const declared = (raw) => marked(raw).bullets
106
+
91
107
  function limits(text) {
92
108
  const prose = text.split(/\n\s*\n/)
93
109
  .map((block) => block.split('\n')
@@ -128,12 +144,23 @@ function warnings(root) {
128
144
  const fromTemplate = new Set(paragraphs(
129
145
  P.withoutComments(P.section(readIfAny(TEMPLATE_WORKSPACE), /Excepciones de autonom/)),
130
146
  ))
131
- const declaredHere = new Set(declared(mine))
132
- const lost = paragraphs(P.withoutComments(mine))
133
- .filter((one) => !fromTemplate.has(one) && !ENUNCIA.test(one) && !declaredHere.has(one))
147
+ // Se descuenta por línea y no por párrafo, que es donde estaba el defecto: `paragraphs` saca el `- ` y
148
+ // une las viñetas seguidas en un párrafo solo, así que una lista declarada no era igual a ninguna
149
+ // entrada de `declared()` y se contaba entera como un límite perdido (caso 159).
150
+ const { bullets, intros } = marked(mine)
151
+ const suyo = new Set([...bullets, ...intros])
152
+ const outside = P.withoutComments(mine).split('\n')
153
+ .filter((line) => !suyo.has(line.replace(BULLET, '').trim()))
154
+ .join('\n')
155
+ const lost = paragraphs(outside)
156
+ .filter((one) => !fromTemplate.has(one) && !ENUNCIA.test(one))
134
157
  if (!lost.length) return []
158
+ // El aviso nombra el camino declarado y no la gramática, aunque los dos sigan valiendo: desde 0.92.0
159
+ // hay una forma de arreglar esto que no pide imitar nada, y mandar a la otra es mandar al camino que
160
+ // el 157 existe para no tener que usar. Quien ya escribió «El runner…» no necesita el aviso — no le
161
+ // sale.
135
162
  return [`organization/workspace.md: ${lost.length} párrafo(s) de "## Excepciones de autonomía" no llegan `
136
- + 'a los agentes porque no arrancan con «El runner», «Debe» o «Nunca»: '
163
+ + 'a los agentes. El que sea un límite va como viñeta bajo `### Límites`: '
137
164
  + `${lost.map((one) => `"${one.slice(0, 60)}…"`).join(', ')}`]
138
165
  }
139
166
 
@@ -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')
@@ -10,14 +14,12 @@ const path = require('node:path')
10
14
  const { spawnSync } = require('node:child_process')
11
15
  const {
12
16
  commandOf, cwdOf, block, isCommit, stagedForCommit,
13
- writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals, configOf,
17
+ writableRoots, outsideRoots, DECLARE_IT, unquoted, opsRoot, withoutGitGlobals,
14
18
  } = require('./input')
15
19
  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
- const SC = require('../core/scope')
21
23
 
22
24
  // Dónde empieza y dónde termina una palabra dentro de un comando. Tres reglas de la tabla de abajo lo
23
25
  // decidían por su cuenta admitiendo sólo un espacio, el principio o el fin, y en un shell una palabra
@@ -396,260 +398,5 @@ function run(program, args, cwd, extra = {}) {
396
398
  }
397
399
  }
398
400
 
399
- // Salidas de build y cachés que cualquier gate rehace solo. Se comparan contra el nombre entero de la
400
- // entrada para que valga también anidado —`packages/app/dist`—, y con el separador de `git status`, que
401
- // siempre usa `/`.
402
- const RECREABLE = new RegExp('(^|/)(?:dist|build|out|coverage|__pycache__'
403
- + '|\\.next|\\.nuxt|\\.svelte-kit|\\.turbo|\\.output|\\.parcel-cache|\\.pytest_cache)$')
404
-
405
- // Dónde tiene que correr un gate: sobre lo que el commit va a grabar, que es el índice y no el árbol.
406
- // El árbol se le parece casi siempre y por eso el error no se veía — puede tener encima otra versión de
407
- // un archivo staged, y puede tener uno sin trackear que el commit no lleva, que es el olvido de
408
- // `git add` de toda la vida. En los dos casos el verde se calcula sobre un código que nadie va a
409
- // commitear, y queda escrito como si fuera el del commit.
410
- //
411
- // Cuando árbol e índice coinciden, el árbol **es** el próximo commit y correr donde está no cuesta nada.
412
- // Sólo cuando difieren se materializa el índice: `checkout-index` sobre un temporal, medido en 157 ms
413
- // para las mil quinientas rutas de este repositorio, contra los segundos que tarda cualquier gate.
414
- //
415
- // Lo ignorado viaja por enlace y lo sin trackear no, y esa distinción es la mitad del arreglo:
416
- // `node_modules` o `.venv` son entorno que el commit no lleva y sin ellos no corre ningún gate, mientras
417
- // que un fuente sin agregar es justamente lo que hay que ver fallar. `git status --ignored` ya los
418
- // separa en `!!` y `??`, así que no hay que adivinar cuál es cuál.
419
- //
420
- // No se usa `git stash --keep-index`, que sería más corto: toca el árbol de quien está trabajando, y un
421
- // gate que muere a la mitad le deja el stash puesto.
422
- function commitTree(dir, input) {
423
- const status = run('git', ['-C', dir, 'status', '--porcelain', '--ignored'], dir)
424
- if (!status.ok) {
425
- block(`no se pudo leer el estado de ${dir}, así que no hay cómo saber qué va a grabar el commit.`)
426
- }
427
- const lines = status.output.split('\n').filter(Boolean)
428
- const delta = lines.filter((line) => !line.startsWith('!!') && line[1] !== ' ')
429
- if (!delta.length) {
430
- return { root: dir, temp: null, env: {} }
431
- }
432
- // La de arriba pregunta *si hay* delta; ésta, *qué* delta: lo que ninguna puerta lee no puede cambiar
433
- // su veredicto, y ahí el árbol vuelve a servir. Las reglas y el porqué viven en `core/scope.js`; sin
434
- // una raíz que declare su alcance esto no cambia nada (caso 156).
435
- //
436
- // La raíz ops no es `dir` —una instancia sidecar las tiene separadas—, así que se resuelve como en el
437
- // resto de los guards en vez de suponer un `ops.config.json` colgando del repositorio: deducir el
438
- // layout en lugar de leer el declarado es lo que costó el caso 158.
439
- const ops = opsRoot(input)
440
- if (SC.staysInTree(ops ? configOf(ops).workspaceRoots : [], dir, delta)) {
441
- return { root: dir, temp: null, env: {} }
442
- }
443
-
444
- const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-verify-'))
445
- const written = run('git', ['-C', dir, 'checkout-index', '-a', `--prefix=${temp}${path.sep}`], dir)
446
- if (!written.ok) {
447
- fs.rmSync(temp, { recursive: true, force: true })
448
- block(`no se pudo materializar el índice de ${dir} para correr los gates: ${written.output}`)
449
- }
450
- const linked = []
451
- for (const line of lines) {
452
- if (!line.startsWith('!! ')) continue
453
- const name = line.slice(3).trim().replace(/\/$/, '')
454
- // Lo que el gate puede fabricar no se le enlaza: lo construye adentro de la copia y se descarta con
455
- // ella. Enlazarlo hacía dos daños a la vez. Uno es del usuario: el gate corre sobre el índice, así
456
- // que le dejaba la salida de build con la versión **staged** mientras su fuente en disco tenía otra,
457
- // y nada lo decía —medido con un `dist/` que pasó de «lo-que-estoy-editando» a «staged» (caso 069)—.
458
- // El otro es del propio gate: construía sobre restos de la corrida anterior del usuario, así que su
459
- // veredicto dependía de un estado que nadie declaró.
460
- //
461
- // La lista envejece y eso pesa menos de lo que parece, porque sólo se aplica a rutas que git ya
462
- // marcó como ignoradas: un `dist/` ignorado es generado por definición. Errarle por defecto —que
463
- // falte un nombre— deja el comportamiento de antes; errarle por exceso hace que un gate reconstruya,
464
- // que es más lento y no incorrecto. Lo que **sí** se enlaza es lo que un gate no puede fabricar:
465
- // `node_modules`, un `.env`, las credenciales de una herramienta.
466
- if (RECREABLE.test(name)) continue
467
- const link = path.join(temp, name)
468
- if (fs.existsSync(link)) continue
469
- fs.mkdirSync(path.dirname(link), { recursive: true })
470
- fs.symlinkSync(path.join(dir, name), link, 'junction')
471
- linked.push(name)
472
- }
473
- // Un índice materializado no trae `.git`, y un gate que llama a git —listar lo trackeado— falla ahí
474
- // por no encontrarlo: el guard frenaría un commit correcto por su propia mecánica. La copia se vuelve
475
- // un repositorio propio, con su índice cargado desde lo que se acaba de materializar, así que `git`
476
- // contesta sobre lo que el commit va a grabar.
477
- //
478
- // Antes esto se resolvía exportando `GIT_DIR` del repositorio de verdad, y ahí el gate que **escribe**
479
- // con git escribía en él: la suite de un proyecto levanta repositorios de prueba y les commitea, y
480
- // esos commits caían en la rama del usuario junto con un `core.worktree` apuntando a un temporal ya
481
- // borrado. Nada lo anunciaba (caso 045).
482
- //
483
- // Lo que se pierde a cambio, y son dos cosas. La copia no tiene historia, así que un gate que lea una
484
- // etiqueta o un `git log` no la encuentra: falla y se ve. Y un gate cuyo efecto ES una escritura de
485
- // git —taggear, commitear un lockfile regenerado— la hace sobre la copia, que se borra: ese efecto se
486
- // pierde en silencio. Se elige el silencio de acá sobre el de antes, que era escribir en la rama de
487
- // quien commitea; un proyecto con un gate así tiene que sacar esa escritura del gate.
488
- const started = run('git', ['init', '--quiet'], temp)
489
- // Lo enlazado es entorno y no entra al índice de la copia, y el `.gitignore` no alcanza para eso: un
490
- // patrón con barra final sólo cubre directorios, y un enlace no lo es para git. `add --all` lo agregaba
491
- // y un gate que recorre lo trackeado lo leía como archivo del commit (caso 095).
492
- if (started.ok) {
493
- fs.mkdirSync(path.join(temp, '.git', 'info'), { recursive: true })
494
- fs.appendFileSync(path.join(temp, '.git', 'info', 'exclude'),
495
- linked.map((name) => `/${name.replace(/[\\*?[\]]/g, '\\$&')}\n`).join(''))
496
- run('git', ['add', '--all'], temp)
497
- }
498
- // Un gate no sólo lee su entorno: escribe en él. Lo ignorado se enlaza al original —eso es a propósito
499
- // y está arriba—, así que lo que el gate escriba cae en el árbol de quien commitea. Un gestor que se
500
- // sincroniza antes de correr un script lo lleva al extremo: ve que el árbol enlazado no coincide con
501
- // el lockfile de la copia y reinstala, lo que **empieza borrando** el `node_modules` del proyecto.
502
- //
503
- // Lo que se apaga es esa comprobación previa, que es el motivo por el que quiere tocar nada.
504
- // `verify-deps-before-run` la gobierna y tiene cinco valores —`install`, `warn`, `prompt`, `error` y
505
- // `false`—. El que hace el daño es `install`, que reinstala solo y es el default desde pnpm 11. `error`
506
- // tampoco serviría: frena el gate cuando el lockfile de la copia difiere de lo instalado, que es justo
507
- // lo que pasa al commitear un cambio de lockfile por partes. La copia no tiene que sincronizar nada:
508
- // tiene que medir el código.
509
- //
510
- // El prefijo es `pnpm_config_` y no `npm_config_`, y esa sola palabra es la diferencia entre apagar la
511
- // comprobación y no apagar nada: pnpm lee sus ajustes del entorno con su propio prefijo, así que con el
512
- // de npm la variable llega igual y se ignora en silencio. Acá estuvo `npm_config_` desde el arreglo del
513
- // 070 y no surtió efecto nunca (caso 151), con lo cual la protección que ese arreglo creyó poner no
514
- // estuvo puesta. Medido sobre pnpm 10.30.2 y 11.20.0, iguales las dos.
515
- //
516
- // Acá estuvo `CI: 'true'` y fue una regresión (caso 070). Resolvía el síntoma del 068 —pnpm dejaba de
517
- // preguntar antes de purgar— desarmando la confirmación en vez de quitarle el motivo, y esa
518
- // confirmación era lo único que protegía al `node_modules` del proyecto: sin ella la reinstalación
519
- // avanza y borra por el enlace. Además encendía `frozen-lockfile`, que el propio pnpm anuncia al
520
- // fallar, así que la variable armaba y desarmaba guardas distintas a la vez.
521
- //
522
- // La regla que queda: no se desarma la confirmación de una herramienta, se le quita el motivo de
523
- // preguntar. Una confirmación que estorba casi siempre está cuidando algo.
524
- return { root: temp, temp, env: { pnpm_config_verify_deps_before_run: 'false' } }
525
- }
526
-
527
- function verify(input) {
528
- if (process.env.OPS_SKIP_VERIFY === '1') return
529
- const command = commandOf(input)
530
- if (!isCommit(command)) return
531
- const { dir, staged } = stagedForCommit(command, cwdOf(input))
532
- const changedOpenApi = staged.some((file) => /^(?:openapi|api|spec)(?:\/.*)?\/[^/]+\.ya?ml$/i.test(file))
533
- || staged.some((file) => /^(?:openapi|swagger)\.ya?ml$/i.test(file))
534
- const changedSqlSource = staged.some((file) => /^(?:db\/queries|queries)\/.*\.sql$/i.test(file))
535
- const hasApiGenerated = staged.some((file) => /(?:^|\/)[^/]*(?:generated|\.gen)\.(?:go|ts|js|py)$/i.test(file))
536
- const hasSqlGenerated = staged.some((file) => /(?:^|\/)(?:sqlc|generated)(?:\/|.*\.(?:go|ts|js|py)$)/i.test(file))
537
- // Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
538
- // es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
539
- // y no de una regla, que es lo que la vuelve una operación y no un permiso.
540
- const sinAprobar = AP.pendingNow(opsRoot(input), staged, input)
541
- const aprobado = !sinAprobar.length
542
- if (changedOpenApi && !hasApiGenerated && !aprobado) {
543
- block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
544
- + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input)}`)
545
- }
546
- if (changedSqlSource && !hasSqlGenerated && !aprobado) {
547
- block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.\n'
548
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
549
- }
550
- if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
551
- const { root, temp, env } = commitTree(dir, input)
552
- try {
553
- verifyGates(root, dir, sinAprobar, env, input)
554
- } finally {
555
- if (temp) fs.rmSync(temp, { recursive: true, force: true })
556
- }
557
- }
558
-
559
- // Corre lo que el stack declare y bloquea si algo sale en rojo. `root` es dónde corre —el índice
560
- // materializado o el árbol, que ahí son lo mismo— y `dir` es el repositorio, que es el nombre que le
561
- // dice algo a quien lee el mensaje.
562
- //
563
- // Cada gate deja su rastro en `ops`; para qué sirve ese registro lo dice `core/evidence.js`. Lo que se
564
- // decide acá es que el rojo se anota igual que el verde: un gate que falló y se commiteó con
565
- // aprobación es exactamente lo que alguien va a querer ver después.
566
- // Lo que se sabe de un gate que falló, en la forma en que se va a leer. El mensaje decía sólo
567
- // `test (exit 1)` y tiraba la salida de la herramienta: cualquier causa —una suite en rojo, un gestor
568
- // que se negó a arrancar el script, un binario que no está— llegaba con el mismo texto. Es la misma
569
- // forma de fallar que el caso 066 encontró en una prueba, acá en el mensaje que lee una persona.
570
- //
571
- // Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
572
- // que hace falta para diagnosticar es la primera línea de error, no el volcado.
573
- const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
574
- // Cómo marca un reporte de pruebas cada resultado. Van sólo las comprobadas contra la herramienta (caso
575
- // 094): el nombre de una prueba verde puede decir «error», y sin mirar la marca la búsqueda por palabra se
576
- // quedaba con ella y el mensaje escondía la roja. Comprobadas con la salida entubada, como la ve un gate:
577
- // `node --test` en spec y en TAP (Node 24.18.0), `go test` (go 1.26.3), jest 30.5.1 (`● nombre`, y
578
- // `● Test suite failed to run`), vitest 5.0.0 (`× nombre`), mocha 12.0.1 (`1) nombre`; la verde es `✔`) y
579
- // pytest 9.1.1 (`FAILED archivo::prueba` en el resumen; `::prueba PASSED` la verde con `-v`). Jest y vitest
580
- // no imprimen las verdes sin `--verbose`, así que de ellos no hay marca de éxito.
581
- const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
582
- const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
583
- const MAX_LINE = 160
584
- function fallo(gate, result) {
585
- // La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
586
- // que lleva el comando entero y no dice nada de qué falló. Descartarla es lo que hace que la primera
587
- // coincidencia sea el error y no el comando — con el eco adentro, un script que **menciona** una
588
- // palabra de error gana siempre.
589
- const lines = (result.output || '').split('\n').map((one) => one.trim())
590
- .filter((one) => one && !one.startsWith('>'))
591
- const line = lines.find((one) => FAILED_TEST.test(one))
592
- || lines.find((one) => !PASSED_TEST.test(one) && ERROR_LINE.test(one)) || lines[0] || ''
593
- return { gate, status: result.status, ms: result.ms, line: line.slice(0, MAX_LINE) }
594
- }
595
-
596
- // Un gate que vuelve en menos de esto no corrió una suite. No se afirma que **no** haya corrido —un
597
- // lint puede fallar rápido y de verdad— y por eso lo que se agrega es el número, no un veredicto: los
598
- // tres gates del caso 068 volvieron a un segundo uno de otro contra los trece de la corrida real.
599
- const DEMASIADO_RAPIDO = 2000
600
- function comoSeLee(failures) {
601
- const texto = failures
602
- .map((one) => `${one.gate} (exit ${one.status}, ${(one.ms / 1000).toFixed(1)} s)`
603
- + `${one.line ? `: ${one.line}` : ''}`)
604
- .join('; ')
605
- if (!failures.every((one) => one.ms < DEMASIADO_RAPIDO)) return texto
606
- const cuantos = failures.length === 1 ? 'Volvió' : `Los ${failures.length} volvieron`
607
- return `${texto}\n${cuantos} en menos de ${DEMASIADO_RAPIDO / 1000} s: eso no alcanza para correr `
608
- + 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
609
- }
610
-
611
- function verifyGates(root, dir, sinAprobar, env, input) {
612
- const ops = opsRoot(input)
613
- const failures = []
614
- if (fs.existsSync(path.join(root, 'package.json'))) {
615
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
616
- const usesPnpm = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
617
- && !fs.existsSync(path.join(root, 'package-lock.json'))
618
- const pm = usesPnpm ? 'pnpm' : 'npm'
619
- for (const script of ['test', 'lint', 'typecheck', 'build']) {
620
- if (!pkg.scripts || !pkg.scripts[script]) continue
621
- const result = run(pm, ['run', script], root, env)
622
- EV.record(ops, script, result.status, result.ms)
623
- if (!result.ok) failures.push(fallo(script, result))
624
- }
625
- } else if (fs.existsSync(path.join(root, 'go.mod'))) {
626
- const makefile = path.join(root, 'Makefile')
627
- if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
628
- const result = run('make', ['ci'], root, env)
629
- EV.record(ops, 'make ci', result.status, result.ms)
630
- if (!result.ok) failures.push(fallo('make ci', result))
631
- } else {
632
- for (const args of [['test', './...'], ['build', './...']]) {
633
- const result = run('go', args, root, env)
634
- EV.record(ops, `go ${args[0]}`, result.status, result.ms)
635
- if (!result.ok) failures.push(fallo(`go ${args[0]}`, result))
636
- }
637
- }
638
- } else if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'requirements.txt'))) {
639
- const makefile = path.join(root, 'Makefile')
640
- if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
641
- const result = run('make', ['test'], root, env)
642
- EV.record(ops, 'make test', result.status, result.ms)
643
- if (!result.ok) failures.push(fallo('make test', result))
644
- }
645
- }
646
- if (!failures.length || !sinAprobar.length) return
647
- // Se dice sobre qué corrió cuando no fue el árbol: un fallo que no se reproduce escribiendo el mismo
648
- // comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
649
- const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
650
- + 'directorio pasa, es que en disco tenés algo que no está staged.'
651
- block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
652
- + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
653
- }
654
401
 
655
- module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run, writesWithBase }
402
+ module.exports = { destructive, gitAdd, dependencies, governance, shellBoundary, run, writesWithBase }
@@ -0,0 +1,275 @@
1
+ 'use strict'
2
+
3
+ // Correr los gates que un commit tiene que pasar, y sobre qué versión del trabajo correrlos.
4
+ //
5
+ // Salió de `shell.js` —donde estaban los seis guards del grupo `pre-shell`— porque no comparte nada con
6
+ // el juicio del texto de un comando: ni una regla, ni el léxico del shell. Lo único que cruza es `run`.
7
+ // Y cambia por otra causa: acá mueven las herramientas —qué prefijo de entorno lee pnpm, cómo marca cada
8
+ // runner una prueba roja—, allá mueven las evasiones que alguien encuentra.
9
+
10
+ const fs = require('node:fs')
11
+ const os = require('node:os')
12
+ const path = require('node:path')
13
+ const { commandOf, cwdOf, block, isCommit, stagedForCommit, opsRoot, configOf } = require('./input')
14
+ const AP = require('./approval')
15
+ const EV = require('../core/evidence')
16
+ const SC = require('../core/scope')
17
+ const { run } = require('./shell')
18
+
19
+ // Salidas de build y cachés que cualquier gate rehace solo. Se comparan contra el nombre entero de la
20
+ // entrada para que valga también anidado —`packages/app/dist`—, y con el separador de `git status`, que
21
+ // siempre usa `/`.
22
+ const RECREABLE = new RegExp('(^|/)(?:dist|build|out|coverage|__pycache__'
23
+ + '|\\.next|\\.nuxt|\\.svelte-kit|\\.turbo|\\.output|\\.parcel-cache|\\.pytest_cache)$')
24
+
25
+ // Dónde tiene que correr un gate: sobre lo que el commit va a grabar, que es el índice y no el árbol.
26
+ // El árbol se le parece casi siempre y por eso el error no se veía — puede tener encima otra versión de
27
+ // un archivo staged, y puede tener uno sin trackear que el commit no lleva, que es el olvido de
28
+ // `git add` de toda la vida. En los dos casos el verde se calcula sobre un código que nadie va a
29
+ // commitear, y queda escrito como si fuera el del commit.
30
+ //
31
+ // Cuando árbol e índice coinciden, el árbol **es** el próximo commit y correr donde está no cuesta nada.
32
+ // Sólo cuando difieren se materializa el índice: `checkout-index` sobre un temporal, medido en 157 ms
33
+ // para las mil quinientas rutas de este repositorio, contra los segundos que tarda cualquier gate.
34
+ //
35
+ // Lo ignorado viaja por enlace y lo sin trackear no, y esa distinción es la mitad del arreglo:
36
+ // `node_modules` o `.venv` son entorno que el commit no lleva y sin ellos no corre ningún gate, mientras
37
+ // que un fuente sin agregar es justamente lo que hay que ver fallar. `git status --ignored` ya los
38
+ // separa en `!!` y `??`, así que no hay que adivinar cuál es cuál.
39
+ //
40
+ // No se usa `git stash --keep-index`, que sería más corto: toca el árbol de quien está trabajando, y un
41
+ // gate que muere a la mitad le deja el stash puesto.
42
+ function commitTree(dir, input) {
43
+ const status = run('git', ['-C', dir, 'status', '--porcelain', '--ignored'], dir)
44
+ if (!status.ok) {
45
+ block(`no se pudo leer el estado de ${dir}, así que no hay cómo saber qué va a grabar el commit.`)
46
+ }
47
+ const lines = status.output.split('\n').filter(Boolean)
48
+ const delta = lines.filter((line) => !line.startsWith('!!') && line[1] !== ' ')
49
+ if (!delta.length) {
50
+ return { root: dir, temp: null, env: {} }
51
+ }
52
+ // La de arriba pregunta *si hay* delta; ésta, *qué* delta: lo que ninguna puerta lee no puede cambiar
53
+ // su veredicto, y ahí el árbol vuelve a servir. Las reglas y el porqué viven en `core/scope.js`; sin
54
+ // una raíz que declare su alcance esto no cambia nada (caso 156).
55
+ //
56
+ // La raíz ops no es `dir` —una instancia sidecar las tiene separadas—, así que se resuelve como en el
57
+ // resto de los guards en vez de suponer un `ops.config.json` colgando del repositorio: deducir el
58
+ // layout en lugar de leer el declarado es lo que costó el caso 158.
59
+ const ops = opsRoot(input)
60
+ if (SC.staysInTree(ops ? configOf(ops).workspaceRoots : [], dir, delta)) {
61
+ return { root: dir, temp: null, env: {} }
62
+ }
63
+
64
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ops-verify-'))
65
+ const written = run('git', ['-C', dir, 'checkout-index', '-a', `--prefix=${temp}${path.sep}`], dir)
66
+ if (!written.ok) {
67
+ fs.rmSync(temp, { recursive: true, force: true })
68
+ block(`no se pudo materializar el índice de ${dir} para correr los gates: ${written.output}`)
69
+ }
70
+ const linked = []
71
+ for (const line of lines) {
72
+ if (!line.startsWith('!! ')) continue
73
+ const name = line.slice(3).trim().replace(/\/$/, '')
74
+ // Lo que el gate puede fabricar no se le enlaza: lo construye adentro de la copia y se descarta con
75
+ // ella. Enlazarlo hacía dos daños a la vez. Uno es del usuario: el gate corre sobre el índice, así
76
+ // que le dejaba la salida de build con la versión **staged** mientras su fuente en disco tenía otra,
77
+ // y nada lo decía —medido con un `dist/` que pasó de «lo-que-estoy-editando» a «staged» (caso 069)—.
78
+ // El otro es del propio gate: construía sobre restos de la corrida anterior del usuario, así que su
79
+ // veredicto dependía de un estado que nadie declaró.
80
+ //
81
+ // La lista envejece y eso pesa menos de lo que parece, porque sólo se aplica a rutas que git ya
82
+ // marcó como ignoradas: un `dist/` ignorado es generado por definición. Errarle por defecto —que
83
+ // falte un nombre— deja el comportamiento de antes; errarle por exceso hace que un gate reconstruya,
84
+ // que es más lento y no incorrecto. Lo que **sí** se enlaza es lo que un gate no puede fabricar:
85
+ // `node_modules`, un `.env`, las credenciales de una herramienta.
86
+ if (RECREABLE.test(name)) continue
87
+ const link = path.join(temp, name)
88
+ if (fs.existsSync(link)) continue
89
+ fs.mkdirSync(path.dirname(link), { recursive: true })
90
+ fs.symlinkSync(path.join(dir, name), link, 'junction')
91
+ linked.push(name)
92
+ }
93
+ // Un índice materializado no trae `.git`, y un gate que llama a git —listar lo trackeado— falla ahí
94
+ // por no encontrarlo: el guard frenaría un commit correcto por su propia mecánica. La copia se vuelve
95
+ // un repositorio propio, con su índice cargado desde lo que se acaba de materializar, así que `git`
96
+ // contesta sobre lo que el commit va a grabar.
97
+ //
98
+ // Antes esto se resolvía exportando `GIT_DIR` del repositorio de verdad, y ahí el gate que **escribe**
99
+ // con git escribía en él: la suite de un proyecto levanta repositorios de prueba y les commitea, y
100
+ // esos commits caían en la rama del usuario junto con un `core.worktree` apuntando a un temporal ya
101
+ // borrado. Nada lo anunciaba (caso 045).
102
+ //
103
+ // Lo que se pierde a cambio, y son dos cosas. La copia no tiene historia, así que un gate que lea una
104
+ // etiqueta o un `git log` no la encuentra: falla y se ve. Y un gate cuyo efecto ES una escritura de
105
+ // git —taggear, commitear un lockfile regenerado— la hace sobre la copia, que se borra: ese efecto se
106
+ // pierde en silencio. Se elige el silencio de acá sobre el de antes, que era escribir en la rama de
107
+ // quien commitea; un proyecto con un gate así tiene que sacar esa escritura del gate.
108
+ const started = run('git', ['init', '--quiet'], temp)
109
+ // Lo enlazado es entorno y no entra al índice de la copia, y el `.gitignore` no alcanza para eso: un
110
+ // patrón con barra final sólo cubre directorios, y un enlace no lo es para git. `add --all` lo agregaba
111
+ // y un gate que recorre lo trackeado lo leía como archivo del commit (caso 095).
112
+ if (started.ok) {
113
+ fs.mkdirSync(path.join(temp, '.git', 'info'), { recursive: true })
114
+ fs.appendFileSync(path.join(temp, '.git', 'info', 'exclude'),
115
+ linked.map((name) => `/${name.replace(/[\\*?[\]]/g, '\\$&')}\n`).join(''))
116
+ run('git', ['add', '--all'], temp)
117
+ }
118
+ // Un gate no sólo lee su entorno: escribe en él. Lo ignorado se enlaza al original —eso es a propósito
119
+ // y está arriba—, así que lo que el gate escriba cae en el árbol de quien commitea. Un gestor que se
120
+ // sincroniza antes de correr un script lo lleva al extremo: ve que el árbol enlazado no coincide con
121
+ // el lockfile de la copia y reinstala, lo que **empieza borrando** el `node_modules` del proyecto.
122
+ //
123
+ // Lo que se apaga es esa comprobación previa, que es el motivo por el que quiere tocar nada.
124
+ // `verify-deps-before-run` la gobierna y tiene cinco valores —`install`, `warn`, `prompt`, `error` y
125
+ // `false`—. El que hace el daño es `install`, que reinstala solo y es el default desde pnpm 11. `error`
126
+ // tampoco serviría: frena el gate cuando el lockfile de la copia difiere de lo instalado, que es justo
127
+ // lo que pasa al commitear un cambio de lockfile por partes. La copia no tiene que sincronizar nada:
128
+ // tiene que medir el código.
129
+ //
130
+ // El prefijo es `pnpm_config_` y no `npm_config_`, y esa sola palabra es la diferencia entre apagar la
131
+ // comprobación y no apagar nada: pnpm lee sus ajustes del entorno con su propio prefijo, así que con el
132
+ // de npm la variable llega igual y se ignora en silencio. Acá estuvo `npm_config_` desde el arreglo del
133
+ // 070 y no surtió efecto nunca (caso 151), con lo cual la protección que ese arreglo creyó poner no
134
+ // estuvo puesta. Medido sobre pnpm 10.30.2 y 11.20.0, iguales las dos.
135
+ //
136
+ // Acá estuvo `CI: 'true'` y fue una regresión (caso 070). Resolvía el síntoma del 068 —pnpm dejaba de
137
+ // preguntar antes de purgar— desarmando la confirmación en vez de quitarle el motivo, y esa
138
+ // confirmación era lo único que protegía al `node_modules` del proyecto: sin ella la reinstalación
139
+ // avanza y borra por el enlace. Además encendía `frozen-lockfile`, que el propio pnpm anuncia al
140
+ // fallar, así que la variable armaba y desarmaba guardas distintas a la vez.
141
+ //
142
+ // La regla que queda: no se desarma la confirmación de una herramienta, se le quita el motivo de
143
+ // preguntar. Una confirmación que estorba casi siempre está cuidando algo.
144
+ return { root: temp, temp, env: { pnpm_config_verify_deps_before_run: 'false' } }
145
+ }
146
+
147
+ function verify(input) {
148
+ if (process.env.OPS_SKIP_VERIFY === '1') return
149
+ const command = commandOf(input)
150
+ if (!isCommit(command)) return
151
+ 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))
155
+ 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))
157
+ // Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
158
+ // es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
159
+ // 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) {
163
+ 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)}`)
165
+ }
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))
169
+ }
170
+ if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
171
+ const { root, temp, env } = commitTree(dir, input)
172
+ try {
173
+ verifyGates(root, dir, sinAprobar, env, input)
174
+ } finally {
175
+ if (temp) fs.rmSync(temp, { recursive: true, force: true })
176
+ }
177
+ }
178
+
179
+ // Corre lo que el stack declare y bloquea si algo sale en rojo. `root` es dónde corre —el índice
180
+ // materializado o el árbol, que ahí son lo mismo— y `dir` es el repositorio, que es el nombre que le
181
+ // dice algo a quien lee el mensaje.
182
+ //
183
+ // Cada gate deja su rastro en `ops`; para qué sirve ese registro lo dice `core/evidence.js`. Lo que se
184
+ // decide acá es que el rojo se anota igual que el verde: un gate que falló y se commiteó con
185
+ // aprobación es exactamente lo que alguien va a querer ver después.
186
+ // Lo que se sabe de un gate que falló, en la forma en que se va a leer. El mensaje decía sólo
187
+ // `test (exit 1)` y tiraba la salida de la herramienta: cualquier causa —una suite en rojo, un gestor
188
+ // que se negó a arrancar el script, un binario que no está— llegaba con el mismo texto. Es la misma
189
+ // forma de fallar que el caso 066 encontró en una prueba, acá en el mensaje que lee una persona.
190
+ //
191
+ // Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
192
+ // que hace falta para diagnosticar es la primera línea de error, no el volcado.
193
+ const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
194
+ // Cómo marca un reporte de pruebas cada resultado. Van sólo las comprobadas contra la herramienta (caso
195
+ // 094): el nombre de una prueba verde puede decir «error», y sin mirar la marca la búsqueda por palabra se
196
+ // quedaba con ella y el mensaje escondía la roja. Comprobadas con la salida entubada, como la ve un gate:
197
+ // `node --test` en spec y en TAP (Node 24.18.0), `go test` (go 1.26.3), jest 30.5.1 (`● nombre`, y
198
+ // `● Test suite failed to run`), vitest 5.0.0 (`× nombre`), mocha 12.0.1 (`1) nombre`; la verde es `✔`) y
199
+ // pytest 9.1.1 (`FAILED archivo::prueba` en el resumen; `::prueba PASSED` la verde con `-v`). Jest y vitest
200
+ // no imprimen las verdes sin `--verbose`, así que de ellos no hay marca de éxito.
201
+ const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
202
+ const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
203
+ const MAX_LINE = 160
204
+ function fallo(gate, result) {
205
+ // La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
206
+ // que lleva el comando entero y no dice nada de qué falló. Descartarla es lo que hace que la primera
207
+ // coincidencia sea el error y no el comando — con el eco adentro, un script que **menciona** una
208
+ // palabra de error gana siempre.
209
+ const lines = (result.output || '').split('\n').map((one) => one.trim())
210
+ .filter((one) => one && !one.startsWith('>'))
211
+ const line = lines.find((one) => FAILED_TEST.test(one))
212
+ || lines.find((one) => !PASSED_TEST.test(one) && ERROR_LINE.test(one)) || lines[0] || ''
213
+ return { gate, status: result.status, ms: result.ms, line: line.slice(0, MAX_LINE) }
214
+ }
215
+
216
+ // Un gate que vuelve en menos de esto no corrió una suite. No se afirma que **no** haya corrido —un
217
+ // lint puede fallar rápido y de verdad— y por eso lo que se agrega es el número, no un veredicto: los
218
+ // tres gates del caso 068 volvieron a un segundo uno de otro contra los trece de la corrida real.
219
+ const DEMASIADO_RAPIDO = 2000
220
+ function comoSeLee(failures) {
221
+ const texto = failures
222
+ .map((one) => `${one.gate} (exit ${one.status}, ${(one.ms / 1000).toFixed(1)} s)`
223
+ + `${one.line ? `: ${one.line}` : ''}`)
224
+ .join('; ')
225
+ if (!failures.every((one) => one.ms < DEMASIADO_RAPIDO)) return texto
226
+ const cuantos = failures.length === 1 ? 'Volvió' : `Los ${failures.length} volvieron`
227
+ return `${texto}\n${cuantos} en menos de ${DEMASIADO_RAPIDO / 1000} s: eso no alcanza para correr `
228
+ + 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
229
+ }
230
+
231
+ function verifyGates(root, dir, sinAprobar, env, input) {
232
+ const ops = opsRoot(input)
233
+ const failures = []
234
+ if (fs.existsSync(path.join(root, 'package.json'))) {
235
+ const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
236
+ const usesPnpm = fs.existsSync(path.join(root, 'pnpm-lock.yaml'))
237
+ && !fs.existsSync(path.join(root, 'package-lock.json'))
238
+ const pm = usesPnpm ? 'pnpm' : 'npm'
239
+ for (const script of ['test', 'lint', 'typecheck', 'build']) {
240
+ if (!pkg.scripts || !pkg.scripts[script]) continue
241
+ const result = run(pm, ['run', script], root, env)
242
+ EV.record(ops, script, result.status, result.ms)
243
+ if (!result.ok) failures.push(fallo(script, result))
244
+ }
245
+ } else if (fs.existsSync(path.join(root, 'go.mod'))) {
246
+ const makefile = path.join(root, 'Makefile')
247
+ if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
248
+ const result = run('make', ['ci'], root, env)
249
+ EV.record(ops, 'make ci', result.status, result.ms)
250
+ if (!result.ok) failures.push(fallo('make ci', result))
251
+ } else {
252
+ for (const args of [['test', './...'], ['build', './...']]) {
253
+ const result = run('go', args, root, env)
254
+ EV.record(ops, `go ${args[0]}`, result.status, result.ms)
255
+ if (!result.ok) failures.push(fallo(`go ${args[0]}`, result))
256
+ }
257
+ }
258
+ } else if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'requirements.txt'))) {
259
+ const makefile = path.join(root, 'Makefile')
260
+ if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
261
+ const result = run('make', ['test'], root, env)
262
+ EV.record(ops, 'make test', result.status, result.ms)
263
+ if (!result.ok) failures.push(fallo('make test', result))
264
+ }
265
+ }
266
+ if (!failures.length || !sinAprobar.length) return
267
+ // Se dice sobre qué corrió cuando no fue el árbol: un fallo que no se reproduce escribiendo el mismo
268
+ // 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 '
270
+ + 'directorio pasa, es que en disco tenés algo que no está staged.'
271
+ block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
272
+ + AP.HOW('OPS_SKIP_VERIFY', sinAprobar, input))
273
+ }
274
+
275
+ module.exports = { verify }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.92.0",
3
+ "version": "0.93.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",