@ingeniomaps/cauce 0.78.0 → 0.80.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.
@@ -6,6 +6,7 @@
6
6
 
7
7
  const fs = require('node:fs')
8
8
  const path = require('node:path')
9
+ const { spawnSync } = require('node:child_process')
9
10
  const {
10
11
  patchOf, filesOf, contentOf, cwdOf, block, configOf, findOpsRoot,
11
12
  writableRoots, outsideRoots, DECLARE_IT,
@@ -26,25 +27,73 @@ function opsRoot(input) {
26
27
  // el guard hasta que cierre la sesión.
27
28
  const approved = (input, file) => !AP.pending(opsRoot(input), [file]).length
28
29
 
30
+ // Si la migración ya viajó a otra copia, que es lo que el bloqueo de abajo quiere saber y `existsSync`
31
+ // no contesta. Devuelve el motivo del bloqueo o cadena vacía.
32
+ //
33
+ // **`HEAD` y no el índice**: un archivo apenas `git add`eado no viajó a ninguna parte, y `git ls-files`
34
+ // lo daría por historial. Y **resolver la raíz es una pregunta aparte** de si el archivo está en `HEAD`:
35
+ // las dos fallan con 128 y confundirlas repite el error que este caso arregla —decidir por la respuesta
36
+ // equivocada—. Sin raíz resoluble se degrada a la conducta de antes, que bloquea de más, porque cuando
37
+ // no se puede saber ése es el lado correcto para equivocarse. Es la degradación que `check` ya declara
38
+ // cuando no puede resolver el repositorio de un servicio. Caso 086.
39
+ function alreadyShipped(file) {
40
+ if (!fs.existsSync(file)) return ''
41
+ const cwd = path.dirname(file)
42
+ const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
43
+ if (top.status !== 0) {
44
+ return 'existe, y acá no hay repositorio con el que saber si ya viajó a otra copia'
45
+ }
46
+ const rel = path.relative(top.stdout.trim(), file).split(path.sep).join('/')
47
+ return spawnSync('git', ['cat-file', '-e', `HEAD:${rel}`], { cwd }).status === 0
48
+ ? 'ya está en el historial del repositorio'
49
+ : ''
50
+ }
51
+
52
+
53
+ // Qué archivo es una credencial, para los dos guards que la cuidan: `secrets`, que frena escribirla, y
54
+ // `secrets-read`, que frena leerla. Devuelve el motivo, o vacío.
55
+ function credential(input, raw) {
56
+ const base = path.basename(raw)
57
+ if (/^(?:\.env|\.env\..+)$/.test(base) && !/\.(?:example|sample|template|schema|dist|tpl)$/.test(base)) {
58
+ return 'parece contener secretos. Edita una plantilla o registra una acción humana.'
59
+ }
60
+ if (/^(?:accesos\.md|credenciales.*|credentials.*\.json|.*service-account.*\.json|.*\.(?:pem|key))$/i.test(base)) {
61
+ return 'parece un archivo de credenciales en texto plano.'
62
+ }
63
+ // Nombres de credencial que la herramienta escribe sola y que la lista anterior no cubría:
64
+ // `.npmrc` guarda el token de publicación, `.netrc` el de cualquier host, `id_rsa` y sus tres
65
+ // hermanas una clave privada de SSH, y `credentials` las de AWS. Los cuatro son estándar, no
66
+ // exóticos — y las claves SSH van por nombre de algoritmo, no por prefijo.
67
+ //
68
+ // Esto tapa un caso conocido; no vuelve completo al guard. La forma de decidir sigue siendo el
69
+ // nombre del archivo, así que otro formato pasa igual — ver «Qué son y qué no son» en el README.
70
+ if (/^(?:\.npmrc|\.netrc|_netrc|\.pypirc|\.dockercfg|id_(?:rsa|dsa|ecdsa|ed25519)|credentials)$/i.test(base)) {
71
+ return 'es un archivo de credenciales que su herramienta mantiene. No lo edites a mano.'
72
+ }
73
+ // Lo que ningún nombre delata: una identidad de máquina que el 088 declara puede llamarse
74
+ // `local-dev.env` (caso 092). Se lee sólo si la declaración existe, para no cargarla en cada hook.
75
+ const root = opsRoot(input)
76
+ if (!root || !fs.existsSync(path.join(root, 'organization', 'secrets.json'))) return ''
77
+ return require('../secrets').identityFiles(root).includes(path.resolve(cwdOf(input), raw))
78
+ ? 'es una identidad declarada en organization/secrets.json: la carga una persona.'
79
+ : ''
80
+ }
81
+
29
82
  function secrets(input) {
30
83
  for (const file of filesOf(input)) {
31
- const base = path.basename(file)
32
- if (/^(?:\.env|\.env\..+)$/.test(base) && !/\.(?:example|sample|template|schema|dist|tpl)$/.test(base)) {
33
- block(`${file} parece contener secretos. Edita una plantilla o registra una acción humana.`)
34
- }
35
- if (/^(?:accesos\.md|credenciales.*|credentials.*\.json|.*service-account.*\.json|.*\.(?:pem|key))$/i.test(base)) {
36
- block(`${file} parece un archivo de credenciales en texto plano.`)
37
- }
38
- // Nombres de credencial que la herramienta escribe sola y que la lista anterior no cubría:
39
- // `.npmrc` guarda el token de publicación, `.netrc` el de cualquier host, `id_rsa` y sus tres
40
- // hermanas una clave privada de SSH, y `credentials` las de AWS. Los cuatro son estándar, no
41
- // exóticos — y las claves SSH van por nombre de algoritmo, no por prefijo.
42
- //
43
- // Esto tapa un caso conocido; no vuelve completo al guard. La forma de decidir sigue siendo el
44
- // nombre del archivo, así que otro formato pasa igual — ver «Qué son y qué no son» en el README.
45
- if (/^(?:\.npmrc|\.netrc|_netrc|\.pypirc|\.dockercfg|id_(?:rsa|dsa|ecdsa|ed25519)|credentials)$/i.test(base)) {
46
- block(`${file} es un archivo de credenciales que su herramienta mantiene. No lo edites a mano.`)
47
- }
84
+ const reason = credential(input, file)
85
+ if (reason) block(`${file} ${reason}`)
86
+ }
87
+ }
88
+
89
+ // Leer una credencial la deja en el contexto de la sesión, y de ahí en los transcripts. Corre en su propio
90
+ // grupo porque los guards de escritura frenarían leer fuera de las raíces o con el WIP vacío.
91
+ function secretsRead(input) {
92
+ if (process.env.OPS_SECRETS_READ_OVERRIDE === '1') return
93
+ for (const file of filesOf(input)) {
94
+ if (!credential(input, file) || approved(input, file)) continue
95
+ block(`${file} es una credencial: leerla la deja en el contexto de la sesión. Si hace falta un valor, `
96
+ + `pedíselo a una persona.\n${AP.HOW('OPS_SECRETS_READ_OVERRIDE', [file])}`)
48
97
  }
49
98
  }
50
99
 
@@ -97,17 +146,18 @@ function testEvidence(input) {
97
146
  'decir que el comportamiento está y pasa a decir que nadie lo miró.\n' +
98
147
  'Si la aserción está mal, corregila; si el comportamiento cambió, cambialo junto con la prueba que ' +
99
148
  'lo fija. Si tiene que quedar afuera igual —flake conocido, entorno que acá no existe—, es una ' +
100
- 'decisión con dueño.\n' + AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE')
149
+ 'decisión con dueño.\n'
150
+ const how = (file) => AP.HOW('OPS_TEST_EVIDENCE_OVERRIDE', [file])
101
151
  for (const match of patchOf(input).matchAll(/^\*\*\* Delete File:\s*(.+)$/gm)) {
102
152
  const removed = match[1].trim()
103
- if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}`)
153
+ if (isTestFile(removed) && !approved(input, removed)) block(`${removed} borra una prueba.\n${why}${how(removed)}`)
104
154
  }
105
155
  const content = contentOf(input)
106
156
  if (!content) return
107
157
  for (const raw of filesOf(input)) {
108
158
  if (!isTestFile(raw) || approved(input, raw)) continue
109
159
  for (const [marca, nombre] of TEST_OFF) {
110
- if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}`)
160
+ if (marca.test(content)) block(`${raw} apaga una prueba con ${nombre}.\n${why}${how(raw)}`)
111
161
  }
112
162
  }
113
163
  }
@@ -126,6 +176,28 @@ function opsOwned(root, file) {
126
176
  return OPS_OWNED.some((prefix) => relative.startsWith(prefix))
127
177
  }
128
178
 
179
+ // Producto es el código de una raíz declarada, y la instancia sidecar no lo es aunque viva dentro de una:
180
+ // `init` escribe `..` como raíz en sidecar, así que la carpeta de la instancia cae adentro. En embedded la
181
+ // raíz de ops **es** una raíz de producto, y ahí sólo se exime lo que la instancia posee. Lo que queda
182
+ // fuera de toda raíz tampoco es producto: el límite de raíces ya lo juzgó, y si pasó es porque el proyecto
183
+ // lo declaró en `writableOutsideRoots` (casos 089 y 090).
184
+ //
185
+ // `ops.config.json` se exime por nombre porque es la llave del límite de raíces: su mensaje manda a
186
+ // editarlo, y frenar esa edición era el candado de arriba con otra forma.
187
+ const INSTANCE_CONFIG = 'ops.config.json'
188
+
189
+ function isProduct(root, file) {
190
+ if (opsOwned(root, file) || file === path.join(root, INSTANCE_CONFIG)) return false
191
+ const declared = configOf(root).workspaceRoots
192
+ const roots = (Array.isArray(declared) ? declared : [])
193
+ .filter((entry) => entry && typeof entry.path === 'string')
194
+ .map((entry) => path.resolve(root, entry.path))
195
+ // Sin raíces legibles no hay contra qué comparar, y se juzga como antes: frenar de más.
196
+ if (!roots.length) return true
197
+ if (outsideRoots(file, roots)) return false
198
+ return outsideRoots(file, [root]) || roots.includes(root)
199
+ }
200
+
129
201
  // R1 y el paso 7 del protocolo piden el plan antes del primer cambio, y hasta acá nadie lo comprobaba:
130
202
  // tocar el archivo primero y redactar después la aceptación que lo justifica sale igual de verde que
131
203
  // hacerlo al revés, y se lee igual en DONE. Lo que se exige es lo mínimo que separa un plan de una
@@ -152,11 +224,10 @@ function planFirst(input) {
152
224
  const why = `${estado}, así que el plan todavía no está escrito.\n`
153
225
  + 'Escribí en tu planning/wip/<runner>.md la tarea y su plan aprobado —pasos numerados, cada uno con un estado '
154
226
  + 'verificable— y volvé al cambio. Si esto no es trabajo de una tarea, aprobá la ruta.\n'
155
- + AP.HOW('OPS_PLAN_FIRST_OVERRIDE')
156
227
  for (const raw of filesOf(input)) {
157
- if (opsOwned(root, path.resolve(cwdOf(input), raw))) continue
228
+ if (!isProduct(root, path.resolve(cwdOf(input), raw))) continue
158
229
  if (approved(input, raw)) continue
159
- block(`${raw} cambia el producto sin plan. ${why}`)
230
+ block(`${raw} cambia el producto sin plan. ${why}${AP.HOW('OPS_PLAN_FIRST_OVERRIDE', [raw])}`)
160
231
  }
161
232
  }
162
233
 
@@ -230,11 +301,17 @@ function migrations(input) {
230
301
  if (!esMigracion.test(normalized)) continue
231
302
  if (approved(input, normalized)) continue
232
303
  if (destructiveSql.test(contentOf(input))) {
233
- block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE')}`)
304
+ block(`${raw} contiene SQL destructivo.\n${AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized])}`)
234
305
  }
306
+ // El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
307
+ // lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
308
+ // la salida angosta, que hasta 0.79.0 sólo tenía el bloqueo hermano: éste es el que aparece en el
309
+ // flujo normal de escribir una migración, así que era justo el que no podía quedarse sin decirla.
235
310
  const file = path.resolve(cwdOf(input), raw)
236
- if (fs.existsSync(file)) {
237
- block(`${raw} es una migración existente. Crea una nueva en vez de reescribir historial.`)
311
+ const shipped = alreadyShipped(file)
312
+ if (shipped) {
313
+ block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
314
+ + AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized]))
238
315
  }
239
316
  }
240
317
  }
@@ -265,6 +342,6 @@ function engineWrites(input) {
265
342
  }
266
343
 
267
344
  module.exports = {
268
- secrets, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
345
+ secrets, secretsRead, integrationSnapshot, generated, testEvidence, planFirst, workspaceBoundary,
269
346
  migrations, engineWrites,
270
347
  }
@@ -47,6 +47,7 @@ const guards = {
47
47
  'integration-snapshot': files.integrationSnapshot,
48
48
  'test-evidence': files.testEvidence,
49
49
  'plan-first': files.planFirst,
50
+ 'secrets-read': files.secretsRead,
50
51
  'planning-drift': planningDrift,
51
52
  }
52
53
 
@@ -55,6 +56,7 @@ const hookGroups = {
55
56
  'pre-shell': ['destructive', 'git-add', 'dependencies', 'governance', 'verify', 'shell-boundary'],
56
57
  'pre-files': ['secrets', 'generated', 'workspace-boundary', 'engine', 'migrations',
57
58
  'integration-snapshot', 'test-evidence', 'plan-first'],
59
+ 'pre-read': ['secrets-read'],
58
60
  stop: ['planning-drift'],
59
61
  }
60
62
 
@@ -94,6 +96,11 @@ const hookMetadata = [
94
96
  event: 'PreToolUse · files',
95
97
  purpose: 'Bloquea escribir secretos, claves privadas y credenciales en texto plano.',
96
98
  },
99
+ {
100
+ name: 'secrets-read',
101
+ event: 'PreToolUse · read',
102
+ purpose: 'Bloquea leer con la herramienta del runner una credencial conocida o declarada.',
103
+ },
97
104
  { name: 'generated', event: 'PreToolUse · files', purpose: 'Impide editar código generado manualmente.' },
98
105
  {
99
106
  name: 'workspace-boundary',
@@ -181,7 +181,7 @@ function dependencies(input) {
181
181
  // `package.json` dice «este manifiesto va sin su lock a propósito» y deja de valer en cuanto el
182
182
  // conjunto cambie. La rama de publicar no pasa por acá y no tiene ruta: sigue arriba, con su variable.
183
183
  const sinAprobar = (parent, names) => AP.pending(opsRoot(input),
184
- names.map((name) => path.posix.join(parent === '.' ? '' : parent, name))).length
184
+ names.map((name) => path.posix.join(parent === '.' ? '' : parent, name)))
185
185
  // Un lock cuenta si está en disco **o** si el commit lo va a llevar, y la unión no es un detalle: el
186
186
  // disco solo perdía el que alguien borró del árbol sin stagear el borrado —sigue en el índice, sigue
187
187
  // en el próximo commit— y ahí la comprobación dejaba de dispararse justo cuando más hacía falta. Es
@@ -201,14 +201,15 @@ function dependencies(input) {
201
201
  if (onDisk.length > 1) {
202
202
  block(`${parent}: hay varios lockfiles (${onDisk.join(', ')}). Conserva uno solo.`)
203
203
  }
204
- if (state.manifests.length && existingLocks.length && !state.locks.length
205
- && sinAprobar(parent, state.manifests)) {
204
+ const manifests = sinAprobar(parent, state.manifests)
205
+ if (state.manifests.length && existingLocks.length && !state.locks.length && manifests.length) {
206
206
  block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.\n`
207
- + AP.HOW('OPS_DEPENDENCIES_OVERRIDE'))
207
+ + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests))
208
208
  }
209
- if (state.locks.length && !state.manifests.length && sinAprobar(parent, state.locks)) {
209
+ const lockfiles = sinAprobar(parent, state.locks)
210
+ if (state.locks.length && !state.manifests.length && lockfiles.length) {
210
211
  block(`${parent}: cambió ${state.locks.join(', ')} sin un cambio explícito en el manifest.\n`
211
- + AP.HOW('OPS_DEPENDENCIES_OVERRIDE'))
212
+ + AP.HOW('OPS_DEPENDENCIES_OVERRIDE', lockfiles))
212
213
  }
213
214
  }
214
215
  }
@@ -348,8 +349,7 @@ function governance(input) {
348
349
  // entre una llave por operación y una puerta que quedó abierta.
349
350
  const pendientes = AP.pending(opsRoot(input), governed)
350
351
  if (!pendientes.length) return
351
- const files = pendientes.map((file) => ` - ${file}`).join('\n')
352
- block(`El commit toca gobernanza protegida:\n${files}\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE')}`)
352
+ block(`El commit toca gobernanza protegida.\n${AP.HOW('OPS_GOVERNANCE_OVERRIDE', pendientes)}`)
353
353
  }
354
354
 
355
355
  function run(program, args, cwd, extra = {}) {
@@ -404,6 +404,7 @@ function commitTree(dir) {
404
404
  fs.rmSync(temp, { recursive: true, force: true })
405
405
  block(`no se pudo materializar el índice de ${dir} para correr los gates: ${written.output}`)
406
406
  }
407
+ const linked = []
407
408
  for (const line of lines) {
408
409
  if (!line.startsWith('!! ')) continue
409
410
  const name = line.slice(3).trim().replace(/\/$/, '')
@@ -424,6 +425,7 @@ function commitTree(dir) {
424
425
  if (fs.existsSync(link)) continue
425
426
  fs.mkdirSync(path.dirname(link), { recursive: true })
426
427
  fs.symlinkSync(path.join(dir, name), link, 'junction')
428
+ linked.push(name)
427
429
  }
428
430
  // Un índice materializado no trae `.git`, y un gate que llama a git —listar lo trackeado— falla ahí
429
431
  // por no encontrarlo: el guard frenaría un commit correcto por su propia mecánica. La copia se vuelve
@@ -441,7 +443,15 @@ function commitTree(dir) {
441
443
  // pierde en silencio. Se elige el silencio de acá sobre el de antes, que era escribir en la rama de
442
444
  // quien commitea; un proyecto con un gate así tiene que sacar esa escritura del gate.
443
445
  const started = run('git', ['init', '--quiet'], temp)
444
- if (started.ok) run('git', ['add', '--all'], temp)
446
+ // Lo enlazado es entorno y no entra al índice de la copia, y el `.gitignore` no alcanza para eso: un
447
+ // patrón con barra final sólo cubre directorios, y un enlace no lo es para git. `add --all` lo agregaba
448
+ // y un gate que recorre lo trackeado lo leía como archivo del commit (caso 095).
449
+ if (started.ok) {
450
+ fs.mkdirSync(path.join(temp, '.git', 'info'), { recursive: true })
451
+ fs.appendFileSync(path.join(temp, '.git', 'info', 'exclude'),
452
+ linked.map((name) => `/${name.replace(/[\\*?[\]]/g, '\\$&')}\n`).join(''))
453
+ run('git', ['add', '--all'], temp)
454
+ }
445
455
  // Un gate no sólo lee su entorno: escribe en él. Lo ignorado se enlaza al original —eso es a propósito
446
456
  // y está arriba—, así que lo que el gate escriba cae en el árbol de quien commitea. Un gestor que se
447
457
  // sincroniza antes de correr un script lo lleva al extremo: ve que el árbol enlazado no coincide con
@@ -478,19 +488,20 @@ function verify(input) {
478
488
  // Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
479
489
  // es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
480
490
  // y no de una regla, que es lo que la vuelve una operación y no un permiso.
481
- const aprobado = !AP.pending(opsRoot(input), staged).length
491
+ const sinAprobar = AP.pending(opsRoot(input), staged)
492
+ const aprobado = !sinAprobar.length
482
493
  if (changedOpenApi && !hasApiGenerated && !aprobado) {
483
494
  block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
484
- + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY')}`)
495
+ + `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', sinAprobar)}`)
485
496
  }
486
497
  if (changedSqlSource && !hasSqlGenerated && !aprobado) {
487
498
  block('Cambió una consulta SQL fuente sin artefactos regenerados. Ejecuta el generador.\n'
488
- + AP.HOW('OPS_SKIP_VERIFY'))
499
+ + AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
489
500
  }
490
501
  if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
491
502
  const { root, temp, env } = commitTree(dir)
492
503
  try {
493
- verifyGates(root, dir, aprobado, env, opsRoot(input))
504
+ verifyGates(root, dir, sinAprobar, env, opsRoot(input))
494
505
  } finally {
495
506
  if (temp) fs.rmSync(temp, { recursive: true, force: true })
496
507
  }
@@ -511,6 +522,11 @@ function verify(input) {
511
522
  // Se muestra **una** línea y acotada: la salida de un gate puede traer cualquier cosa del entorno, y lo
512
523
  // que hace falta para diagnosticar es la primera línea de error, no el volcado.
513
524
  const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
525
+ // Cómo marca un reporte de pruebas cada resultado: `node --test` en spec y en TAP, y `go test`. Van sólo
526
+ // las comprobadas contra la herramienta (caso 094): el nombre de una prueba verde puede decir «error», y
527
+ // sin mirar la marca la búsqueda por palabra se quedaba con ella y el mensaje escondía la roja.
528
+ const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:)/
529
+ const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)/
514
530
  const MAX_LINE = 160
515
531
  function fallo(gate, result) {
516
532
  // La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
@@ -519,7 +535,8 @@ function fallo(gate, result) {
519
535
  // palabra de error gana siempre.
520
536
  const lines = (result.output || '').split('\n').map((one) => one.trim())
521
537
  .filter((one) => one && !one.startsWith('>'))
522
- const line = lines.find((one) => ERROR_LINE.test(one)) || lines[0] || ''
538
+ const line = lines.find((one) => FAILED_TEST.test(one))
539
+ || lines.find((one) => !PASSED_TEST.test(one) && ERROR_LINE.test(one)) || lines[0] || ''
523
540
  return { gate, status: result.status, ms: result.ms, line: line.slice(0, MAX_LINE) }
524
541
  }
525
542
 
@@ -538,7 +555,7 @@ function comoSeLee(failures) {
538
555
  + 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
539
556
  }
540
557
 
541
- function verifyGates(root, dir, aprobado, env, ops) {
558
+ function verifyGates(root, dir, sinAprobar, env, ops) {
542
559
  const failures = []
543
560
  if (fs.existsSync(path.join(root, 'package.json'))) {
544
561
  const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))
@@ -572,13 +589,13 @@ function verifyGates(root, dir, aprobado, env, ops) {
572
589
  if (!result.ok) failures.push(fallo('make test', result))
573
590
  }
574
591
  }
575
- if (!failures.length || aprobado) return
592
+ if (!failures.length || !sinAprobar.length) return
576
593
  // Se dice sobre qué corrió cuando no fue el árbol: un fallo que no se reproduce escribiendo el mismo
577
594
  // comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
578
595
  const donde = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
579
596
  + 'directorio pasa, es que en disco tenés algo que no está staged.'
580
597
  block(`Verify falló en ${path.basename(dir)}: ${comoSeLee(failures)}\nNo se commitea en rojo.${donde}\n`
581
- + AP.HOW('OPS_SKIP_VERIFY'))
598
+ + AP.HOW('OPS_SKIP_VERIFY', sinAprobar))
582
599
  }
583
600
 
584
601
  module.exports = { destructive, gitAdd, dependencies, governance, verify, shellBoundary, run }
@@ -161,4 +161,4 @@ async function fetchItems(config, options = {}) {
161
161
  return issues.map((issue) => normalizeIssue(issue, config))
162
162
  }
163
163
 
164
- module.exports = { fetchItems, normalizeFixture, validateConfig }
164
+ module.exports = { contract: 1, fetchItems, normalizeFixture, validateConfig }
@@ -37,9 +37,31 @@ function providerConfig(root, name) {
37
37
  return { registry, entry, config: readJson(configFile), configFile }
38
38
  }
39
39
 
40
- function adapter(name) {
41
- if (name === 'jira') return require('./providers/jira')
42
- throw new Error(`No existe adaptador para ${name}`)
40
+ // Los adaptadores que trae Cauce. Uno de la empresa no va acá: se declara con una ruta en el registro de
41
+ // la instancia y vive en la carpeta de su proveedor (caso 091).
42
+ const BUILTIN = { jira: () => require('./providers/jira') }
43
+ // La versión del contrato que el motor sabe llamar. A un adaptador de la empresa no lo toca `upgrade`, así
44
+ // que sin esto un cambio de interfaz lo rompería en silencio.
45
+ const CONTRACT = 1
46
+ const CONTRACT_FUNCTIONS = ['validateConfig', 'fetchItems', 'normalizeFixture']
47
+
48
+ function adapter(root, name, entry = {}) {
49
+ const declared = String(entry.adapter || '')
50
+ let impl
51
+ if (Object.hasOwn(BUILTIN, declared)) impl = BUILTIN[declared]()
52
+ else if (declared.startsWith('./')) {
53
+ const base = path.join(root, 'integrations', name)
54
+ impl = require(F.assertWithin(base, path.resolve(base, declared), `${name}: adapter`))
55
+ } else {
56
+ throw new Error(`No existe adaptador para ${declared || name}: usá uno de Cauce `
57
+ + `(${Object.keys(BUILTIN).join(', ')}) o una ruta ./ dentro de integrations/${name}/`)
58
+ }
59
+ if (impl.contract !== CONTRACT) {
60
+ throw new Error(`el motor sabe llamar contract ${CONTRACT} y el adaptador declara ${impl.contract}`)
61
+ }
62
+ const missing = CONTRACT_FUNCTIONS.filter((fn) => typeof impl[fn] !== 'function')
63
+ if (missing.length) throw new Error(`al adaptador le falta ${missing.join(', ')}`)
64
+ return impl
43
65
  }
44
66
 
45
67
  function sensitivePath(value, trail = '') {
@@ -91,7 +113,7 @@ function validate(root, onlyProvider = '') {
91
113
  const secret = sensitivePath(loaded.config)
92
114
  if (secret) errors.push(`${name}: ${secret} no puede contener secretos; usa una variable de entorno`)
93
115
  try {
94
- adapter(name).validateConfig(loaded.config, errors)
116
+ adapter(root, name, loaded.entry).validateConfig(loaded.config, errors)
95
117
  } catch (error) {
96
118
  errors.push(`${name}: ${error.message}`)
97
119
  }
@@ -185,7 +207,7 @@ async function sync(root, name, options = {}) {
185
207
  // Son dos interruptores y se exigen los dos: el del registro dice que el proveedor está conectado
186
208
  // al proyecto, y el suyo que hay a dónde apuntar.
187
209
  if (!entry.enabled || !config.enabled) throw new Error(`${name} está deshabilitado`)
188
- const provider = adapter(name)
210
+ const provider = adapter(root, name, entry)
189
211
  const items = options.fixture
190
212
  ? provider.normalizeFixture(readJson(path.resolve(options.fixture)), config)
191
213
  : await provider.fetchItems(config)
@@ -420,6 +442,7 @@ module.exports = {
420
442
  providerConfig,
421
443
  reconcile,
422
444
  safeSegment,
445
+ sensitivePath,
423
446
  sync,
424
447
  validate,
425
448
  writebackPlan,
@@ -0,0 +1,201 @@
1
+ 'use strict'
2
+
3
+ // El contrato de secretos que una empresa comparte entre sus repositorios, y el chequeo sin red que lo
4
+ // hace cumplir (caso 088). La base no conoce ningún gestor: lee `organization/secrets.json`, comprueba
5
+ // que sus referencias cierren, que ninguna credencial viva dentro de un repositorio y que cada copia de
6
+ // un archivo compartido coincida con la canónica que guarda la instancia. Lo que habla con el gestor y
7
+ // lo que se copia es de la empresa; esto sólo dice qué quedó atrás y cómo ponerlo al día.
8
+ //
9
+ // Compara los servicios de **una** instancia. Una empresa con varios proyectos los declara como raíces
10
+ // de la misma, y eso es lo que hace que un esqueleto y sus derivados se midan contra la misma copia.
11
+
12
+ const fs = require('node:fs')
13
+ const path = require('node:path')
14
+ const crypto = require('node:crypto')
15
+ const { spawnSync } = require('node:child_process')
16
+ const F = require('../core/files')
17
+ const { resolvePath } = require('../config/paths')
18
+ const { sensitivePath } = require('../integrations/registry')
19
+
20
+ const DECLARATION = path.join('organization', 'secrets.json')
21
+ const TOP_LEVEL = ['schemaVersion', 'accounts', 'projects', 'identities', 'shared', 'services']
22
+ const SOURCES = ['file', 'ci-secret']
23
+
24
+ function inside(base, target) {
25
+ try { F.assertWithin(base, target); return true } catch { return false }
26
+ }
27
+
28
+ const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex')
29
+
30
+ // Una credencial dentro de un repositorio es la que se commitea por error, y el repositorio puede ser
31
+ // uno que la instancia no declara: sólo git sabe si el directorio está en un árbol de trabajo. Sin
32
+ // `GIT_DIR` heredado, que respondería por otro repositorio (caso 045).
33
+ function inRepository(file) {
34
+ const dir = path.dirname(file)
35
+ if (!fs.existsSync(dir)) return false
36
+ const env = { ...process.env }
37
+ delete env.GIT_DIR
38
+ delete env.GIT_WORK_TREE
39
+ const result = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: dir, encoding: 'utf8', env })
40
+ return result.status === 0 && result.stdout.trim() === 'true'
41
+ }
42
+
43
+ function readJson(file) {
44
+ try { return { value: JSON.parse(fs.readFileSync(file, 'utf8')) } } catch (error) { return { error } }
45
+ }
46
+
47
+ const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value)
48
+
49
+ // Una sección que falta es una sección vacía; una que no es objeto es un error y se lee como vacía para
50
+ // que el resto del chequeo siga diciendo lo que encuentre.
51
+ function section(declaration, name, errors) {
52
+ const value = declaration[name]
53
+ if (value === undefined) return {}
54
+ if (isObject(value) && Object.values(value).every(isObject)) return value
55
+ errors.push(`${DECLARATION}: ${name} debe ser un objeto de entradas`)
56
+ return {}
57
+ }
58
+
59
+ function reference(errors, where, field, value, declared, sectionName) {
60
+ if (value === undefined) return errors.push(`${where}: falta ${field}`)
61
+ if (!Object.hasOwn(declared, value)) {
62
+ errors.push(`${where}: ${field} «${value}» no está declarado en ${sectionName}`)
63
+ }
64
+ }
65
+
66
+ function checkIdentities(context) {
67
+ const { root, identities, accounts, workspaces, errors, warnings } = context
68
+ for (const [name, identity] of Object.entries(identities)) {
69
+ const where = `identities.${name}`
70
+ reference(errors, where, 'account', identity.account, accounts, 'accounts')
71
+ if (!SOURCES.includes(identity.source)) {
72
+ errors.push(`${where}: source debe ser ${SOURCES.join(' o ')}`)
73
+ continue
74
+ }
75
+ if (identity.source === 'ci-secret') {
76
+ if (identity.file !== undefined) errors.push(`${where}: una identidad ci-secret vive en el CI y no lleva file`)
77
+ continue
78
+ }
79
+ if (typeof identity.file !== 'string' || !identity.file.trim()) {
80
+ errors.push(`${where}: una identidad file necesita la ruta de su archivo`)
81
+ continue
82
+ }
83
+ const file = resolvePath(root, identity.file)
84
+ const base = [root, ...workspaces.map((workspace) => workspace.dir)].find((dir) => inside(dir, file))
85
+ if (base) errors.push(`${where}: ${file} está dentro de ${base}; una credencial vive fuera de todo repositorio`)
86
+ else if (inRepository(file)) {
87
+ errors.push(`${where}: ${file} está dentro de un repositorio de git; una credencial vive fuera de todos`)
88
+ }
89
+ if (!fs.existsSync(file)) {
90
+ warnings.push(`${where}: ${file} no está en esta máquina; la carga una persona, el chequeo no la lee`)
91
+ }
92
+ }
93
+ }
94
+
95
+ // Las copias de cada servicio contra la canónica. Devuelve cuántos servicios quedaron al día.
96
+ function checkServices(context) {
97
+ const { root, declaration, services, projects, identities, shared, workspaces, errors } = context
98
+ let current = 0
99
+ for (const [name, service] of Object.entries(services)) {
100
+ const where = `services.${name}`
101
+ const before = errors.length
102
+ reference(errors, where, 'project', service.project, projects, 'projects')
103
+ if (service.identity !== undefined) {
104
+ reference(errors, where, 'identity', service.identity, identities, 'identities')
105
+ }
106
+ const workspace = workspaces.find((entry) => entry.name === service.root)
107
+ if (!workspace) {
108
+ errors.push(`${where}: root «${service.root}» no es una raíz de ops.config.json`)
109
+ continue
110
+ }
111
+ if (!fs.existsSync(workspace.dir)) {
112
+ errors.push(`${where}: no existe ${workspace.dir}`)
113
+ continue
114
+ }
115
+ const schema = service.schema || '.env.schema'
116
+ if (!fs.existsSync(path.resolve(workspace.dir, schema))) {
117
+ errors.push(`${where}: falta ${schema} en ${workspace.dir}`)
118
+ }
119
+ for (const [target, key] of Object.entries(isObject(service.files) ? service.files : {})) {
120
+ if (!Object.hasOwn(shared, key)) {
121
+ errors.push(`${where}: files.${target} apunta a «${key}», que no está declarado en shared`)
122
+ continue
123
+ }
124
+ const copy = path.resolve(workspace.dir, target)
125
+ const canonical = path.resolve(root, declaration.shared[key])
126
+ if (!inside(workspace.dir, copy)) errors.push(`${where}: ${target} está fuera de su raíz`)
127
+ else if (!fs.existsSync(canonical)) continue
128
+ else if (!fs.existsSync(copy)) errors.push(`${where}: falta ${target}; copiala de ${declaration.shared[key]}`)
129
+ else if (digest(copy) !== digest(canonical)) {
130
+ errors.push(`${where}: ${target} no coincide con ${declaration.shared[key]}; para ponerla al día: `
131
+ + `cp ${canonical} ${copy}`)
132
+ }
133
+ }
134
+ if (errors.length === before) current += 1
135
+ }
136
+ return current
137
+ }
138
+
139
+ function checkShared(root, shared, errors) {
140
+ for (const [key, value] of Object.entries(shared)) {
141
+ const file = path.resolve(root, String(value))
142
+ if (!inside(root, file)) errors.push(`shared.${key}: ${value} está fuera de la instancia`)
143
+ else if (!fs.existsSync(file)) errors.push(`shared.${key}: no existe ${value}`)
144
+ }
145
+ }
146
+
147
+ // El chequeo entero. Sin declaración no hay nada que comprobar, y no es un error: la mayoría de las
148
+ // instancias no la usa.
149
+ function check(root) {
150
+ const file = path.join(root, DECLARATION)
151
+ if (!fs.existsSync(file)) return { declared: false, errors: [], warnings: [], current: 0 }
152
+ const errors = []
153
+ const warnings = []
154
+ const done = () => ({ declared: true, errors, warnings, current })
155
+ let current = 0
156
+ const read = readJson(file)
157
+ if (read.error) errors.push(`${DECLARATION}: JSON inválido (${read.error.message})`)
158
+ else if (!isObject(read.value)) errors.push(`${DECLARATION}: debe ser un objeto`)
159
+ if (errors.length) return done()
160
+ const declaration = read.value
161
+ for (const key of Object.keys(declaration)) {
162
+ if (!TOP_LEVEL.includes(key)) errors.push(`${DECLARATION}: propiedad desconocida ${key}`)
163
+ }
164
+ if (declaration.schemaVersion !== 1) errors.push(`${DECLARATION}: schemaVersion debe ser 1`)
165
+ const secret = sensitivePath(declaration)
166
+ if (secret) errors.push(`${DECLARATION}: ${secret} tiene forma de secreto; acá van referencias, nunca valores`)
167
+ const config = readJson(path.join(root, 'ops.config.json'))
168
+ const roots = config.error || !Array.isArray(config.value.workspaceRoots) ? [] : config.value.workspaceRoots
169
+ if (config.error) errors.push(`ops.config.json: no se puede leer (${config.error.message})`)
170
+ const workspaces = roots.filter(isObject).map((entry) => ({ name: entry.name, dir: path.resolve(root, entry.path) }))
171
+ const accounts = section(declaration, 'accounts', errors)
172
+ const projects = section(declaration, 'projects', errors)
173
+ const identities = section(declaration, 'identities', errors)
174
+ const services = section(declaration, 'services', errors)
175
+ const shared = isObject(declaration.shared) ? declaration.shared : {}
176
+ if (declaration.shared !== undefined && !isObject(declaration.shared)) {
177
+ errors.push(`${DECLARATION}: shared debe ser un objeto de rutas`)
178
+ }
179
+ for (const [name, project] of Object.entries(projects)) {
180
+ reference(errors, `projects.${name}`, 'account', project.account, accounts, 'accounts')
181
+ }
182
+ const context = { root, declaration, accounts, projects, identities, services, shared, workspaces, errors, warnings }
183
+ checkIdentities(context)
184
+ checkShared(root, shared, errors)
185
+ current = checkServices(context)
186
+ return done()
187
+ }
188
+
189
+ // Las rutas de las identidades que la declaración pone en disco, resueltas como las resuelve el chequeo.
190
+ // Las lee el guard de secretos (caso 092); una declaración ausente o ilegible no aporta ninguna, porque
191
+ // decir qué está mal es trabajo del chequeo.
192
+ function identityFiles(root) {
193
+ const read = readJson(path.join(root, DECLARATION))
194
+ if (read.error || !isObject(read.value) || !isObject(read.value.identities)) return []
195
+ return Object.values(read.value.identities)
196
+ .filter((identity) => isObject(identity) && identity.source === 'file')
197
+ .filter((identity) => typeof identity.file === 'string' && identity.file.trim())
198
+ .map((identity) => resolvePath(root, identity.file))
199
+ }
200
+
201
+ module.exports = { DECLARATION, check, identityFiles }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ingeniomaps/cauce",
3
- "version": "0.78.0",
3
+ "version": "0.80.0",
4
4
  "description": "Sistema portable de planificación y ejecución verificable para cualquier proyecto",
5
5
  "keywords": [
6
6
  "planning",
@@ -151,6 +151,7 @@ y eso lo deja apagado **hasta que cierres la sesión**, no para un comando.
151
151
  | `OPS_TEST_EVIDENCE_OVERRIDE=1` | el de evidencia de pruebas |
152
152
  | `OPS_DEPENDENCIES_OVERRIDE=1` | el de dependencias, incluido publicar e instalar global |
153
153
  | `OPS_PLAN_FIRST_OVERRIDE=1` | el que exige plan antes de cambiar el producto |
154
+ | `OPS_SECRETS_READ_OVERRIDE=1` | el que frena leer una credencial con la herramienta del runner |
154
155
  | `OPS_SKIP_VERIFY=1` | el que corre los gates |
155
156
 
156
157
  Por eso la aprobación es la vía recomendada y esto es lo que queda cuando no alcanza: prendela para lo