@ingeniomaps/cauce 0.92.0 → 0.94.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 +134 -0
- package/automatization/workflows/autobuild.js +0 -1
- package/engine/agents/learning.js +0 -2
- package/engine/automation/check.js +95 -0
- package/engine/automation/index.js +7 -74
- package/engine/automation/rules.js +27 -9
- package/engine/cli/contract.js +37 -10
- package/engine/hooks/run.js +2 -1
- package/engine/hooks/shell.js +9 -262
- package/engine/hooks/verify.js +275 -0
- package/package.json +1 -1
- package/template/planning/rules/README.md +8 -5
- package/template/planning/rules/system/code-shape.md +19 -0
- package/template/planning/rules/system/commits.md +35 -0
- package/template/planning/rules/system/conduct.md +23 -0
- package/template/planning/rules/system/process.md +48 -118
- package/template/planning/rules/system/runs.md +153 -0
package/engine/hooks/shell.js
CHANGED
|
@@ -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,
|
|
4
|
-
// dependencia y qué
|
|
5
|
-
//
|
|
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,
|
|
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,
|
|
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
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
`system/` pertenece a Cauce y se reemplaza completo en cada actualización:
|
|
4
4
|
|
|
5
|
-
- `system/process.md` — R1..R4,
|
|
6
|
-
|
|
7
|
-
- `system/
|
|
5
|
+
- `system/process.md` — R1..R4, R17, R25, R28: planificación, review, sincronización de estado, cómo se
|
|
6
|
+
parte una unidad de trabajo y cómo se nombra y se deja escrito su estado.
|
|
7
|
+
- `system/runs.md` — R16, R20..R22, R26: lo que cuesta una corrida, cuándo una medición vale, cómo se
|
|
8
|
+
retoma lo interrumpido, qué no se toca mientras se mide y qué le cuesta una puerta a quien la corre.
|
|
9
|
+
- `system/code-shape.md` — R5..R7, R11, R18, R27: simplicidad, forma del cambio y cómo se escribe una
|
|
10
|
+
defensa.
|
|
8
11
|
- `system/commits.md` — R8..R10: historia versionada y entrega.
|
|
9
|
-
- `system/conduct.md` — R12..R15, R19, R23: trato con sistemas externos, lo que llega de ellos, lo
|
|
10
|
-
se destruye, y la obligación de entregar al negarse.
|
|
12
|
+
- `system/conduct.md` — R12..R15, R19, R23, R24: trato con sistemas externos, lo que llega de ellos, lo
|
|
13
|
+
que se afirma del propio código, lo que se destruye, y la obligación de entregar al negarse.
|
|
11
14
|
|
|
12
15
|
El número es el identificador: una regla se cita por él desde un cargo, un workflow o una entrada de
|
|
13
16
|
DONE, y por eso no se reordena ni se reusa.
|
|
@@ -75,3 +75,22 @@ quedar verde con el defecto adentro sin que sobre un solo doble — lo que falta
|
|
|
75
75
|
escribió. Por eso se justifica y se reúsa en vez de prohibirse: un doble sin razón escrita se copia, y la
|
|
76
76
|
copia deja de coincidir con lo que reemplaza sin que ninguna prueba lo note.
|
|
77
77
|
|
|
78
|
+
|
|
79
|
+
## R27 — Una defensa se aplica por defecto y cada excepción se declara sola
|
|
80
|
+
|
|
81
|
+
Una defensa escrita como lista de lo que protege deja abierto todo lo que venga después. El día que
|
|
82
|
+
alguien agrega una ruta, un campo o un comando, nace afuera —y no falla nada, porque no hay nada que
|
|
83
|
+
compare la lista con lo que existe—. La ausencia no deja rastro, que es la forma de R15 aplicada a un
|
|
84
|
+
mecanismo en vez de a un entregable.
|
|
85
|
+
|
|
86
|
+
Se escribe al revés: cerrado por defecto, y cada excepción declarada de a una, con su razón y en el
|
|
87
|
+
mismo lugar. Así lo que se agrega nace protegido y lo que se abre deja constancia de quién lo abrió.
|
|
88
|
+
|
|
89
|
+
No es una regla de seguridad aunque ahí sea donde más se note. Vale para un guard, para el alcance de un
|
|
90
|
+
lint, para lo que un CI corre, para un permiso: cualquier mecanismo cuya lista alguien tiene que
|
|
91
|
+
acordarse de ampliar.
|
|
92
|
+
|
|
93
|
+
Y el caso que más se disfraza es la excepción por ambiente. «Esta comprobación no corre en desarrollo»
|
|
94
|
+
es una quita escrita como agregado —R9—, y además garantiza que el camino de producción sea el único que
|
|
95
|
+
nunca se ejercitó. Si hace falta que en desarrollo sea más fácil, se le da a desarrollo lo que necesita
|
|
96
|
+
para pasar la comprobación, no una puerta trasera.
|