@ingeniomaps/cauce 0.97.0 → 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +162 -0
- package/README.md +1 -0
- package/agents/roles/system/accounting-specialist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/ai-governance-lead/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/ai-product-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/analytics-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/backend-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/business-strategist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/cloud-architect/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/community-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/content-specialist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/customer-success-manager/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/customer-support-specialist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/data-analyst/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/data-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/data-governance-steward/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/data-scientist/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/database-administrator/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/developer-relations-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/devops-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/engineering-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/financial-controller/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/finops-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/fraud-risk-analyst/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/frontend-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/growth-marketer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/implementation-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/integrations-engineer/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/kyc-aml-specialist/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/legal-counsel/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/logistics-operations-manager/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/machine-learning-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/mlops-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/mobile-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/partnerships-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/people-operations-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/privacy-compliance-specialist/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/procurement-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/product-manager/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/product-marketing-manager/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/project-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/qa-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/release-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/sales-representative/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/security-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/site-reliability-engineer/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/software-architect/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/solutions-engineer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/tech-lead/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/technical-program-manager/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/technical-writer/learning/AUTOMATION.md +1 -1
- package/agents/roles/system/treasury-analyst/learning/AUTOMATION.md +2 -2
- package/agents/roles/system/ui-designer/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/user-researcher/learning/AUTOMATION.md +3 -3
- package/agents/roles/system/ux-designer/learning/AUTOMATION.md +3 -3
- package/automatization/hooks/README.md +10 -5
- package/automatization/runners/antigravity/README.md +4 -1
- package/automatization/runners/antigravity/hook.js +84 -22
- package/automatization/runners/antigravity/manifest.json +1 -0
- package/automatization/shared/acceptance.js +26 -0
- package/automatization/workflows/agent-propose.js +2 -2
- package/automatization/workflows/autobuild.js +121 -38
- package/engine/agents/learning-files.js +18 -0
- package/engine/agents/learning-seal.js +24 -12
- package/engine/agents/learning-sources.js +12 -10
- package/engine/agents/learning.js +29 -7
- package/engine/automation/index.js +11 -2
- package/engine/automation/registration.js +89 -0
- package/engine/cli/args.js +2 -2
- package/engine/cli/catalog.js +12 -12
- package/engine/cli/ops.js +2 -1
- package/engine/cli/validate.js +3 -0
- package/engine/cli/wiring.js +1 -1
- package/engine/config/validate.js +30 -11
- package/engine/core/migrations.js +275 -0
- package/engine/core/repos.js +18 -4
- package/engine/hooks/approval.js +38 -7
- package/engine/hooks/chat.js +97 -27
- package/engine/hooks/files.js +6 -104
- package/engine/hooks/input.js +67 -11
- package/engine/hooks/migrations.js +93 -0
- package/engine/hooks/push.js +8 -5
- package/engine/hooks/run.js +10 -5
- package/engine/hooks/self-approval.js +2 -2
- package/engine/hooks/shell.js +34 -0
- package/engine/hooks/verify.js +79 -22
- package/engine/planning/acceptance.js +18 -0
- package/engine/planning/contracts.js +78 -45
- package/engine/schemas/ops-config.schema.json +9 -0
- package/package.json +1 -1
- package/template/AGENTS.md +8 -5
- package/template/Makefile +1 -1
- package/template/planning/PROTOCOL.md +6 -2
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El guard `migrations`: no reescribir una migración que ya viajó y no aplicar un borrado sin que una
|
|
4
|
+
// persona lo apruebe. Qué archivo es una migración, qué parte aplica y qué cuenta como destruir lo decide
|
|
5
|
+
// `core/migrations`, que `check` también lee.
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs')
|
|
8
|
+
const path = require('node:path')
|
|
9
|
+
const { spawnSync } = require('node:child_process')
|
|
10
|
+
const { filesOf, contentOf, patchOf, cwdOf, block, configOf, opsRoot } = require('./input')
|
|
11
|
+
const AP = require('./approval')
|
|
12
|
+
const M = require('../core/migrations')
|
|
13
|
+
|
|
14
|
+
// Si la migración ya viajó a otra copia, que es lo que el bloqueo de abajo quiere saber y `existsSync`
|
|
15
|
+
// no contesta. Devuelve el motivo del bloqueo o cadena vacía.
|
|
16
|
+
//
|
|
17
|
+
// **`HEAD` y no el índice**: un archivo apenas `git add`eado no viajó a ninguna parte, y `git ls-files`
|
|
18
|
+
// lo daría por historial. Y **resolver la raíz es una pregunta aparte** de si el archivo está en `HEAD`:
|
|
19
|
+
// las dos fallan con 128 y confundirlas repite el error que este caso arregla —decidir por la respuesta
|
|
20
|
+
// equivocada—. Sin raíz resoluble se degrada a la conducta de antes, que bloquea de más, porque cuando
|
|
21
|
+
// no se puede saber ése es el lado correcto para equivocarse. Es la degradación que `check` ya declara
|
|
22
|
+
// cuando no puede resolver el repositorio de un servicio. Caso 086.
|
|
23
|
+
function alreadyShipped(file) {
|
|
24
|
+
if (!fs.existsSync(file)) return ''
|
|
25
|
+
const cwd = path.dirname(file)
|
|
26
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8' })
|
|
27
|
+
if (top.status !== 0) {
|
|
28
|
+
return 'existe, y acá no hay repositorio con el que saber si ya viajó a otra copia'
|
|
29
|
+
}
|
|
30
|
+
const rel = path.relative(top.stdout.trim(), file).split(path.sep).join('/')
|
|
31
|
+
return spawnSync('git', ['cat-file', '-e', `HEAD:${rel}`], { cwd }).status === 0
|
|
32
|
+
? 'ya está en el historial del repositorio'
|
|
33
|
+
: ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const approved = (input, file) => !AP.pending(opsRoot(input), [file], input).length
|
|
37
|
+
|
|
38
|
+
// El `Edit` trae lo que reemplaza; el archivo en disco dice dónde cae (caso 185). Se lo lee sólo para
|
|
39
|
+
// ubicar el fragmento: lo que ya estaba no se juzga.
|
|
40
|
+
function editOf(input, file) {
|
|
41
|
+
const fields = input.tool_input || {}
|
|
42
|
+
if (typeof fields.old_string !== 'string' || typeof fields.content === 'string') return {}
|
|
43
|
+
let disk = null
|
|
44
|
+
try { disk = fs.readFileSync(file, 'utf8') } catch { /* sin archivo se juzga el fragmento */ }
|
|
45
|
+
return { disk, old: fields.old_string, all: fields.replace_all === true }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function migrations(input) {
|
|
49
|
+
if (process.env.OPS_MIGRATIONS_OVERRIDE === '1') return
|
|
50
|
+
// Las dos condiciones deciden sobre el mismo alcance, y por eso comparten el filtro. El bloqueo por
|
|
51
|
+
// SQL destructivo corría antes de este bucle, o sea sobre el contenido y sin mirar la ruta que ya
|
|
52
|
+
// tenía a mano: frenaba un ADR que citaba la migración o un comentario que advertía que eso no se
|
|
53
|
+
// hace, y encima afirmaba «La migración contiene…» sobre un archivo que no lo era. Un guard que
|
|
54
|
+
// frena donde no corresponde enseña a apagarlo, que es la salida más ancha que hay.
|
|
55
|
+
//
|
|
56
|
+
// El precio de compartir el filtro es que una migración escrita fuera de las carpetas declaradas deja de
|
|
57
|
+
// frenarse. Es deliberado: el otro chequeo ya vivía con esa convención, y dos condiciones de la misma
|
|
58
|
+
// función con dos alcances distintos es lo que hizo falta arreglar acá.
|
|
59
|
+
const root = opsRoot(input)
|
|
60
|
+
const isMigration = M.pattern(root ? configOf(root) : {})
|
|
61
|
+
// Un parche trae varios archivos en un sobre: cada uno se juzga por su sección, no por el sobre entero.
|
|
62
|
+
const sections = M.patchSections(patchOf(input))
|
|
63
|
+
for (const raw of filesOf(input)) {
|
|
64
|
+
const normalized = raw.replace(/\\/g, '/')
|
|
65
|
+
if (!isMigration.test(normalized)) continue
|
|
66
|
+
if (approved(input, normalized)) continue
|
|
67
|
+
const file = path.resolve(cwdOf(input), raw)
|
|
68
|
+
// El mensaje nombra el archivo y la sentencia: un falso positivo se lee igual que un bloqueo correcto
|
|
69
|
+
// mientras no diga sobre qué está decidiendo. Y cuando la migración se partió, dice que la reversión
|
|
70
|
+
// no se juzgó, para que quien lo lea no busque la sentencia en el bloque equivocado.
|
|
71
|
+
const section = sections.get(raw)
|
|
72
|
+
const scope = section
|
|
73
|
+
? M.judgedPatch(normalized, section)
|
|
74
|
+
: M.judged(normalized, contentOf(input), editOf(input, file))
|
|
75
|
+
const found = M.destructive(scope.text)
|
|
76
|
+
if (found) {
|
|
77
|
+
const where = scope.label ? ` en el bloque que aplica (la reversión, \`${scope.label}\`, no se juzga)` : ''
|
|
78
|
+
block(`${raw} contiene ${found.kind}${where}: \`${found.what}\`.\n`
|
|
79
|
+
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
|
|
80
|
+
}
|
|
81
|
+
// El mensaje nombra el hecho que sostiene el bloqueo y no su interpretación: «historial» era una
|
|
82
|
+
// lectura que `existsSync` no podía dar, y se la daba igual sobre stubs de la misma sesión. Y lleva
|
|
83
|
+
// la salida angosta, que hasta 0.79.0 sólo tenía el bloqueo hermano: éste es el que aparece en el
|
|
84
|
+
// flujo normal de escribir una migración, así que era justo el que no podía quedarse sin decirla.
|
|
85
|
+
const shipped = alreadyShipped(file)
|
|
86
|
+
if (shipped) {
|
|
87
|
+
block(`${raw} ${shipped}. Crea una nueva en vez de reescribirla.\n`
|
|
88
|
+
+ AP.HOW('OPS_MIGRATIONS_OVERRIDE', [normalized], input))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { migrations }
|
package/engine/hooks/push.js
CHANGED
|
@@ -105,7 +105,7 @@ function liveMessage(live, input) {
|
|
|
105
105
|
// una sesión real, con «pegando tal cual» a secas el agente se ofreció a escribirse la aprobación.
|
|
106
106
|
return `'git push' publica cambios en ${names}, la rama viva, y requiere una acción humana: ni `
|
|
107
107
|
+ 'runner.allowPush ni una orden en el chat llegan ahí sin un permiso por rama. Decile a la persona qué '
|
|
108
|
-
+ 'se frenó y esperá:
|
|
108
|
+
+ 'se frenó y esperá: confirmarlo en el chat no lo destraba. Lo da ella, nombrando la rama en '
|
|
109
109
|
+ 'runner.pushToLiveBranches de ops.config.json, que la deja como una rama de trabajo'
|
|
110
110
|
+ (lines.length ? `, o pegando ella tal cual en ${AP.where(input)} estas líneas:\n`
|
|
111
111
|
+ lines.map((line) => ` ${line}\n`).join('') : '.')
|
|
@@ -113,11 +113,14 @@ function liveMessage(live, input) {
|
|
|
113
113
|
|
|
114
114
|
function workMessage(items, input) {
|
|
115
115
|
const lines = items.filter((item) => item.startsWith('push '))
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
116
|
+
const held = CHAT.hold(input, items)
|
|
117
|
+
const dropped = held ? held.dropped : []
|
|
118
|
+
const chat = held && dropped.length < items.length
|
|
119
|
+
const how = (dropped.length ? AP.REFUSED(dropped, input) : '') + (chat
|
|
120
|
+
? 'Decile a la persona qué se frenó y pedile que lo confirme con sus palabras: si lo que contesta es un '
|
|
121
|
+
+ 'sí, reintentá el mismo push y pasa; si duda, pregunta o dice que no, no reintentes. '
|
|
119
122
|
+ 'También pasa si lo pide nombrando el remoto y la rama, como «subí feat/x a origin».'
|
|
120
|
-
: 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.'
|
|
123
|
+
: 'Lo destraba una persona pidiéndolo en el chat con el remoto y la rama.')
|
|
121
124
|
const paste = lines.length
|
|
122
125
|
? `\nSi prefiere aprobarlo a mano, que pegue ella tal cual en ${AP.where(input)} estas líneas:\n`
|
|
123
126
|
+ lines.map((line) => ` ${line}\n`).join('')
|
package/engine/hooks/run.js
CHANGED
|
@@ -13,6 +13,7 @@ const { readInput, block, opsRoot } = require('./input')
|
|
|
13
13
|
const shell = require('./shell')
|
|
14
14
|
const { verify } = require('./verify')
|
|
15
15
|
const files = require('./files')
|
|
16
|
+
const { migrations } = require('./migrations')
|
|
16
17
|
const chat = require('./chat')
|
|
17
18
|
const { secretsShell } = require('./secrets-shell')
|
|
18
19
|
const { opsConfig, opsConfigShell } = require('./ops-config')
|
|
@@ -50,7 +51,7 @@ const guards = {
|
|
|
50
51
|
generated: files.generated,
|
|
51
52
|
'workspace-boundary': files.workspaceBoundary,
|
|
52
53
|
engine: files.engineWrites,
|
|
53
|
-
migrations
|
|
54
|
+
migrations,
|
|
54
55
|
'integration-snapshot': files.integrationSnapshot,
|
|
55
56
|
'test-evidence': files.testEvidence,
|
|
56
57
|
'plan-first': files.planFirst,
|
|
@@ -145,8 +146,10 @@ const hookMetadata = [
|
|
|
145
146
|
{
|
|
146
147
|
name: 'migrations',
|
|
147
148
|
event: 'PreToolUse · files',
|
|
148
|
-
purpose: 'Protege migraciones existentes y bloquea SQL destructivo
|
|
149
|
-
+ '
|
|
149
|
+
purpose: 'Protege migraciones existentes y bloquea SQL destructivo o el borrado con la API del ORM en la '
|
|
150
|
+
+ 'parte que aplica —no en su reversión—, sobre las extensiones y carpetas que el proyecto declare en '
|
|
151
|
+
+ 'migrations.extensions y migrations.paths — sólo .sql bajo migrations/, migration/ o migrate/ si no '
|
|
152
|
+
+ 'declara nada.',
|
|
150
153
|
},
|
|
151
154
|
{
|
|
152
155
|
name: 'integration-snapshot',
|
|
@@ -194,11 +197,13 @@ function executeAll(names, input) {
|
|
|
194
197
|
for (const name of resolve(names)) execute(name, input)
|
|
195
198
|
}
|
|
196
199
|
|
|
200
|
+
// Asíncrono sólo por la lectura de stdin, que necesita un plazo (`input.js`); los guards siguen siendo
|
|
201
|
+
// sincrónicos y `executeAll` también, así que quien los llama directo no cambia.
|
|
197
202
|
if (require.main === module) {
|
|
198
|
-
|
|
203
|
+
readInput().then((input) => executeAll(process.argv.slice(2), input)).catch((error) => {
|
|
199
204
|
console.error(`BLOQUEADO: ${error.message}`)
|
|
200
205
|
process.exit(error.blocked ? 2 : 1)
|
|
201
|
-
}
|
|
206
|
+
})
|
|
202
207
|
}
|
|
203
208
|
|
|
204
209
|
module.exports = {
|
|
@@ -24,7 +24,7 @@ const CHAT_RECORD = (file) => `${file} es el registro de lo que la persona dijo
|
|
|
24
24
|
+ 'runner, nunca una herramienta.'
|
|
25
25
|
|
|
26
26
|
const SELF = (file) => `${file} es la aprobación de una persona, y escribírsela es aprobarse solo. Si la `
|
|
27
|
-
+ 'persona quiere autorizar algo, que lo diga en el chat —nombrándolo, o
|
|
27
|
+
+ 'persona quiere autorizar algo, que lo diga en el chat —nombrándolo, o confirmando el bloqueo— o '
|
|
28
28
|
+ 'que edite el archivo ella.'
|
|
29
29
|
|
|
30
30
|
// Las líneas van en el mensaje porque son lo que el «dale» va a aprobar: una confirmación que no muestra
|
|
@@ -32,7 +32,7 @@ const SELF = (file) => `${file} es la aprobación de una persona, y escribírsel
|
|
|
32
32
|
const UNASKED = (file, missing) => `${file} es la aprobación de una persona y estas líneas no las pidió:\n`
|
|
33
33
|
+ missing.map((line) => ` ${line}\n`).join('')
|
|
34
34
|
+ 'Escribí sólo lo que ella nombró en su mensaje. Si hacen falta las otras, decile cuáles y por qué, y '
|
|
35
|
-
+ '
|
|
35
|
+
+ 'pedile que lo confirme con sus palabras: si lo que contesta es un sí, reintentá la misma escritura y pasa.'
|
|
36
36
|
|
|
37
37
|
// **Por shell se frena toda escritura.** Es la misma decisión que toma `ops-config`, y por qué un comando
|
|
38
38
|
// no se puede comparar está escrito allá. Lo que la trae hasta acá es que el contenido es lo único que
|
package/engine/hooks/shell.js
CHANGED
|
@@ -193,6 +193,39 @@ function gitAdd(input) {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// Un `package.json` cambia sin tocar nada de lo que su lockfile registra: un script, una descripción, la
|
|
197
|
+
// configuración de una herramienta. Frenar eso por la sola presencia del archivo en el índice obligaba a
|
|
198
|
+
// pedir permiso por un cambio sin riesgo (caso 181). Se deja pasar sólo lo medido, y lo demás frena como
|
|
199
|
+
// antes, incluido lo que no se puede comparar.
|
|
200
|
+
//
|
|
201
|
+
// La lista salió de correr `npm install --package-lock-only` con npm 11.16.0 cambiando una clave por vez:
|
|
202
|
+
// las claves name, version, license, engines, bin, funding, workspaces, os y cpu, y los scripts preinstall,
|
|
203
|
+
// install y postinstall, sí mueven el lock. Sólo se midió el lock de npm, así que con otro gestor no se
|
|
204
|
+
// afloja nada.
|
|
205
|
+
const INERT_KEYS = new Set([
|
|
206
|
+
'scripts', 'description', 'keywords', 'author', 'repository', 'homepage', 'bugs', 'private', 'type',
|
|
207
|
+
'main', 'files', 'exports', 'config', 'jest', 'eslintConfig', 'prettier',
|
|
208
|
+
])
|
|
209
|
+
const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall']
|
|
210
|
+
|
|
211
|
+
function lockUnaffected(dir, file, lockfiles) {
|
|
212
|
+
if (path.basename(file) !== 'package.json' || lockfiles.join() !== 'package-lock.json') return false
|
|
213
|
+
const read = (ref) => {
|
|
214
|
+
const shown = run('git', ['-C', dir, 'show', `${ref}:${file}`], dir)
|
|
215
|
+
if (!shown.ok) return null
|
|
216
|
+
try { return JSON.parse(shown.output) } catch { return null }
|
|
217
|
+
}
|
|
218
|
+
const before = read('HEAD')
|
|
219
|
+
const after = read('')
|
|
220
|
+
if (!before || !after || typeof before !== 'object' || typeof after !== 'object') return false
|
|
221
|
+
const same = (one, other) => JSON.stringify(one) === JSON.stringify(other)
|
|
222
|
+
const changed = [...new Set([...Object.keys(before), ...Object.keys(after)])]
|
|
223
|
+
.filter((key) => !same(before[key], after[key]))
|
|
224
|
+
if (changed.some((key) => !INERT_KEYS.has(key))) return false
|
|
225
|
+
const scripts = (one) => (one && typeof one.scripts === 'object' ? one.scripts : {}) || {}
|
|
226
|
+
return INSTALL_SCRIPTS.every((name) => same(scripts(before)[name], scripts(after)[name]))
|
|
227
|
+
}
|
|
228
|
+
|
|
196
229
|
function dependencies(input) {
|
|
197
230
|
if (process.env.OPS_DEPENDENCIES_OVERRIDE === '1') return
|
|
198
231
|
const command = commandOf(input)
|
|
@@ -250,6 +283,7 @@ function dependencies(input) {
|
|
|
250
283
|
block(`${parent}: hay varios lockfiles (${onDisk.join(', ')}). Conserva uno solo.`)
|
|
251
284
|
}
|
|
252
285
|
const manifests = sinAprobar(parent, state.manifests)
|
|
286
|
+
.filter((name) => !lockUnaffected(dir, name, existingLocks))
|
|
253
287
|
if (state.manifests.length && existingLocks.length && !state.locks.length && manifests.length) {
|
|
254
288
|
block(`${parent}: cambió ${state.manifests.join(', ')} sin actualizar su lockfile.\n`
|
|
255
289
|
+ AP.HOW('OPS_DEPENDENCIES_OVERRIDE', manifests, input))
|
package/engine/hooks/verify.js
CHANGED
|
@@ -144,33 +144,90 @@ function commitTree(dir, input) {
|
|
|
144
144
|
return { root: temp, temp, env: { pnpm_config_verify_deps_before_run: 'false' } }
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// Dónde viven la consulta de sqlc y su generado lo declara cada proyecto en su config (`queries:` y
|
|
148
|
+
// `gen.go.out`), y acá no se lee: no hay parser de YAML y no se agrega uno para un guard. Lo que se usa
|
|
149
|
+
// en su lugar son dos hechos que no dependen del layout.
|
|
150
|
+
//
|
|
151
|
+
// La consulta se busca en `queries/` a cualquier profundidad —un monorepo la tiene en `api/db/queries/`
|
|
152
|
+
// (caso 192)—, y sólo cuenta si el repositorio tiene un `sqlc.yaml`, `sqlc.yml` o `sqlc.json`, los tres
|
|
153
|
+
// nombres que sqlc busca: sin esa condición, desanclar el patrón frenaría consultas de un reporte o
|
|
154
|
+
// fixtures que nadie genera, y cada freno falso se resuelve aprobándolo. Un `queries:` con otro nombre de
|
|
155
|
+
// carpeta sigue sin verse.
|
|
156
|
+
//
|
|
157
|
+
// El generado se reconoce por el nombre del archivo, no por su carpeta: sqlc escribe `<consulta>.sql.go`
|
|
158
|
+
// donde diga `out`, y esperar una carpeta `sqlc/` frenaba al que la llamó de otro modo (caso 187). La
|
|
159
|
+
// carpeta queda como respaldo. `.ts` o `.py` no: esos generadores son plugins y su nombre no se comprobó.
|
|
160
|
+
const SQL_SOURCE = /(?:^|\/)queries\/.*\.sql$/i
|
|
161
|
+
const SQL_GENERATED = /\.sql\.go$|(?:^|\/)(?:sqlc|generated)(?:\/|.*\.(?:go|ts|js|py)$)/i
|
|
162
|
+
function usesSqlc(dir) {
|
|
163
|
+
// `top` porque `dir` puede ser un subdirectorio y el índice se pregunta entero; `glob` para que `**/`
|
|
164
|
+
// alcance también la raíz. Se lee el índice y no el disco: una config sin trackear no es del proyecto.
|
|
165
|
+
// Si git no contesta se asume que sí: un guard que no pudo mirar no afloja.
|
|
166
|
+
const listed = run('git', ['-C', dir, 'ls-files', '--',
|
|
167
|
+
...['yaml', 'yml', 'json'].map((ext) => `:(top,glob)**/sqlc.${ext}`)], dir)
|
|
168
|
+
return !listed.ok || Boolean(listed.output.trim())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Una especificación OpenAPI se reconoce por lo que declara y no por la carpeta: `api/` guarda también la
|
|
172
|
+
// config de sqlc, un compose o fixtures, y cada uno pedía regenerar un cliente que no existe (caso 197). La
|
|
173
|
+
// carpeta queda como filtro barato antes de leer nada. Un fragmento de una especificación partida con `$ref`
|
|
174
|
+
// no declara nada, así que cuenta si su carpeta de primer nivel tiene en el índice una raíz que sí lo haga.
|
|
175
|
+
// Se lee el índice y no el disco, que es lo que el commit graba. Si git no contesta, dispara: un guard que
|
|
176
|
+
// no pudo mirar no afloja.
|
|
177
|
+
const OPENAPI_CANDIDATE = /^(?:(?:openapi|api|spec)\/(?:.*\/)?[^/]+|openapi|swagger)\.ya?ml$/i
|
|
178
|
+
const OPENAPI_ROOT = /^(?:openapi|swagger)\s*:/m
|
|
179
|
+
|
|
180
|
+
function declaresOpenApi(dir, file) {
|
|
181
|
+
// Un archivo borrado figura como cambio y ya no está en el índice: lo que era se lee en `HEAD`.
|
|
182
|
+
for (const revision of ['', 'HEAD']) {
|
|
183
|
+
const shown = run('git', ['-C', dir, 'show', `${revision}:${file}`], dir)
|
|
184
|
+
if (shown.ok) return OPENAPI_ROOT.test(shown.output.slice(0, 4096))
|
|
185
|
+
}
|
|
186
|
+
return true
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function changedOpenApiSpec(dir, staged) {
|
|
190
|
+
const candidates = staged.filter((file) => OPENAPI_CANDIDATE.test(file))
|
|
191
|
+
if (candidates.some((file) => declaresOpenApi(dir, file))) return true
|
|
192
|
+
// Una sola búsqueda en el índice por todas las carpetas, y no un `git show` por archivo: un `spec/` con
|
|
193
|
+
// cientos de fixtures costaba cientos de procesos por commit. `git grep` sale con 1 si no encuentra nada, y
|
|
194
|
+
// con cualquier otro código no pudo mirar, que dispara igual.
|
|
195
|
+
const folders = [...new Set(candidates.filter((file) => file.includes('/')).map((file) => file.split('/')[0]))]
|
|
196
|
+
if (!folders.length) return false
|
|
197
|
+
const found = run('git', ['-C', dir, 'grep', '--cached', '-l', '-E', '^(openapi|swagger)[[:space:]]*:', '--',
|
|
198
|
+
...folders.flatMap((folder) => [`:(top,glob)${folder}/**/*.yaml`, `:(top,glob)${folder}/**/*.yml`])], dir)
|
|
199
|
+
if (found.ok) return Boolean(found.output.trim())
|
|
200
|
+
return found.status !== 1
|
|
201
|
+
}
|
|
202
|
+
|
|
147
203
|
function verify(input) {
|
|
148
204
|
if (process.env.OPS_SKIP_VERIFY === '1') return
|
|
149
205
|
const command = commandOf(input)
|
|
150
206
|
if (!isCommit(command)) return
|
|
151
207
|
const { dir, staged } = stagedForCommit(command, cwdOf(input))
|
|
152
|
-
const changedOpenApi =
|
|
153
|
-
|
|
154
|
-
const changedSqlSource = staged.some((file) => /^(?:db\/queries|queries)\/.*\.sql$/i.test(file))
|
|
208
|
+
const changedOpenApi = changedOpenApiSpec(dir, staged)
|
|
209
|
+
const changedSqlSource = staged.some((file) => SQL_SOURCE.test(file)) && usesSqlc(dir)
|
|
155
210
|
const hasApiGenerated = staged.some((file) => /(?:^|\/)[^/]*(?:generated|\.gen)\.(?:go|ts|js|py)$/i.test(file))
|
|
156
|
-
const hasSqlGenerated = staged.some((file) =>
|
|
211
|
+
const hasSqlGenerated = staged.some((file) => SQL_GENERATED.test(file))
|
|
157
212
|
// Acá lo aprobado es el conjunto staged entero: decir «autorizo commitear exactamente estas rutas»
|
|
158
213
|
// es lo que un gate en rojo necesita, y cambia en cuanto se stagea una más. La lista sale del índice
|
|
159
214
|
// y no de una regla, que es lo que la vuelve una operación y no un permiso.
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
if (changedOpenApi && !hasApiGenerated && !
|
|
215
|
+
const unapproved = AP.pendingNow(opsRoot(input), staged, input)
|
|
216
|
+
const approved = !unapproved.length
|
|
217
|
+
if (changedOpenApi && !hasApiGenerated && !approved) {
|
|
163
218
|
block('Cambió una fuente OpenAPI/Swagger sin incluir código regenerado. Ejecuta el generador y '
|
|
164
|
-
+ `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY',
|
|
219
|
+
+ `stagea su salida.\n${AP.HOW('OPS_SKIP_VERIFY', unapproved, input)}`)
|
|
165
220
|
}
|
|
166
|
-
if (changedSqlSource && !hasSqlGenerated && !
|
|
167
|
-
block('Cambió una consulta SQL fuente sin artefactos regenerados
|
|
168
|
-
+
|
|
221
|
+
if (changedSqlSource && !hasSqlGenerated && !approved) {
|
|
222
|
+
block('Cambió una consulta SQL fuente sin artefactos regenerados: busqué en el índice un `*.sql.go`, '
|
|
223
|
+
+ 'o algo bajo una carpeta `sqlc/` o `generated/`, y no hay ninguno. Si corriste `sqlc generate`, '
|
|
224
|
+
+ 'stageá lo que escribió; si su `output_files_suffix` le cambia el nombre, esto no lo reconoce.\n'
|
|
225
|
+
+ AP.HOW('OPS_SKIP_VERIFY', unapproved, input))
|
|
169
226
|
}
|
|
170
227
|
if (!staged.some((file) => /\.(?:ts|tsx|js|jsx|mjs|cjs|go|py|html|css|scss|prisma)$/.test(file))) return
|
|
171
228
|
const { root, temp, env } = commitTree(dir, input)
|
|
172
229
|
try {
|
|
173
|
-
verifyGates(root, dir,
|
|
230
|
+
verifyGates(root, dir, unapproved, env, input)
|
|
174
231
|
} finally {
|
|
175
232
|
if (temp) fs.rmSync(temp, { recursive: true, force: true })
|
|
176
233
|
}
|
|
@@ -201,7 +258,7 @@ const ERROR_LINE = /error|err[_!]|fail|abort|not found|cannot|no such/i
|
|
|
201
258
|
const FAILED_TEST = /^(?:✖|not ok\b|--- FAIL:|● |× |\d+\) |FAILED )/
|
|
202
259
|
const PASSED_TEST = /^(?:✔|ok\b|--- PASS:)|::\S+ PASSED\b/
|
|
203
260
|
const MAX_LINE = 160
|
|
204
|
-
function
|
|
261
|
+
function failure(gate, result) {
|
|
205
262
|
// La línea que empieza con `>` es el eco del script que npm y pnpm imprimen antes de correrlo, así
|
|
206
263
|
// que lleva el comando entero y no dice nada de qué falló. Descartarla es lo que hace que la primera
|
|
207
264
|
// coincidencia sea el error y no el comando — con el eco adentro, un script que **menciona** una
|
|
@@ -228,7 +285,7 @@ function howItReads(failures) {
|
|
|
228
285
|
+ 'una suite, así que mirá si llegaron a ejecutarse antes de aprobar esto como un rojo conocido.'
|
|
229
286
|
}
|
|
230
287
|
|
|
231
|
-
function verifyGates(root, dir,
|
|
288
|
+
function verifyGates(root, dir, unapproved, env, input) {
|
|
232
289
|
const ops = opsRoot(input)
|
|
233
290
|
const failures = []
|
|
234
291
|
if (fs.existsSync(path.join(root, 'package.json'))) {
|
|
@@ -240,19 +297,19 @@ function verifyGates(root, dir, sinAprobar, env, input) {
|
|
|
240
297
|
if (!pkg.scripts || !pkg.scripts[script]) continue
|
|
241
298
|
const result = run(pm, ['run', script], root, env)
|
|
242
299
|
EV.record(ops, script, result.status, result.ms)
|
|
243
|
-
if (!result.ok) failures.push(
|
|
300
|
+
if (!result.ok) failures.push(failure(script, result))
|
|
244
301
|
}
|
|
245
302
|
} else if (fs.existsSync(path.join(root, 'go.mod'))) {
|
|
246
303
|
const makefile = path.join(root, 'Makefile')
|
|
247
304
|
if (fs.existsSync(makefile) && /^ci:/m.test(fs.readFileSync(makefile, 'utf8'))) {
|
|
248
305
|
const result = run('make', ['ci'], root, env)
|
|
249
306
|
EV.record(ops, 'make ci', result.status, result.ms)
|
|
250
|
-
if (!result.ok) failures.push(
|
|
307
|
+
if (!result.ok) failures.push(failure('make ci', result))
|
|
251
308
|
} else {
|
|
252
309
|
for (const args of [['test', './...'], ['build', './...']]) {
|
|
253
310
|
const result = run('go', args, root, env)
|
|
254
311
|
EV.record(ops, `go ${args[0]}`, result.status, result.ms)
|
|
255
|
-
if (!result.ok) failures.push(
|
|
312
|
+
if (!result.ok) failures.push(failure(`go ${args[0]}`, result))
|
|
256
313
|
}
|
|
257
314
|
}
|
|
258
315
|
} else if (fs.existsSync(path.join(root, 'pyproject.toml')) || fs.existsSync(path.join(root, 'requirements.txt'))) {
|
|
@@ -260,16 +317,16 @@ function verifyGates(root, dir, sinAprobar, env, input) {
|
|
|
260
317
|
if (fs.existsSync(makefile) && /^test:/m.test(fs.readFileSync(makefile, 'utf8'))) {
|
|
261
318
|
const result = run('make', ['test'], root, env)
|
|
262
319
|
EV.record(ops, 'make test', result.status, result.ms)
|
|
263
|
-
if (!result.ok) failures.push(
|
|
320
|
+
if (!result.ok) failures.push(failure('make test', result))
|
|
264
321
|
}
|
|
265
322
|
}
|
|
266
|
-
if (!failures.length || !
|
|
323
|
+
if (!failures.length || !unapproved.length) return
|
|
267
324
|
// Se dice sobre qué corrió cuando no fue el árbol: un fallo que no se reproduce escribiendo el mismo
|
|
268
325
|
// comando a mano se lee como que el guard miente, y lo que pasó es que midió lo que se va a grabar.
|
|
269
|
-
const
|
|
326
|
+
const where = root === dir ? '' : '\nCorrió sobre el índice, que es lo que el commit graba: si en tu '
|
|
270
327
|
+ 'directorio pasa, es que en disco tenés algo que no está staged.'
|
|
271
|
-
block(`Verify falló en ${path.basename(dir)}: ${howItReads(failures)}\nNo se commitea en rojo.${
|
|
272
|
-
+ AP.HOW('OPS_SKIP_VERIFY',
|
|
328
|
+
block(`Verify falló en ${path.basename(dir)}: ${howItReads(failures)}\nNo se commitea en rojo.${where}\n`
|
|
329
|
+
+ AP.HOW('OPS_SKIP_VERIFY', unapproved, input))
|
|
273
330
|
}
|
|
274
331
|
|
|
275
332
|
module.exports = { verify }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// El lado del motor de `automatization/shared/acceptance.js`: lo evalúa en vez de copiarlo. El archivo
|
|
4
|
+
// declara constantes sueltas porque así lo incluye un recorrido, y un fragmento así no se puede
|
|
5
|
+
// `require`; evaluado aparte da los mismos valores que recibe el recorrido renderizado.
|
|
6
|
+
|
|
7
|
+
const fs = require('node:fs')
|
|
8
|
+
const path = require('node:path')
|
|
9
|
+
const vm = require('node:vm')
|
|
10
|
+
|
|
11
|
+
const SOURCE = path.join(__dirname, '..', '..', 'automatization', 'shared', 'acceptance.js')
|
|
12
|
+
|
|
13
|
+
const { acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE } = vm.runInNewContext(
|
|
14
|
+
`${fs.readFileSync(SOURCE, 'utf8')}\n;({ acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE })`,
|
|
15
|
+
{}, { filename: SOURCE },
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
module.exports = { acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE }
|
|
@@ -4,6 +4,7 @@ const fs = require('node:fs')
|
|
|
4
4
|
const path = require('node:path')
|
|
5
5
|
const P = require('./parser')
|
|
6
6
|
const { PLACEHOLDERS } = require('../core/onboarding')
|
|
7
|
+
const { acceptanceConditions, OUT_OF_VERIFY, NON_EXECUTABLE } = require('./acceptance')
|
|
7
8
|
|
|
8
9
|
const TEST_TRACE = /^(?:n\/a\s*[—-]\s*.+|(?:A|C\d+)\s*(?:→|->)\s*\S.+)$/i
|
|
9
10
|
const DECISION_TRACE = /\[(?:fuente|supuesto):\s*[^\]]+\]/i
|
|
@@ -40,12 +41,13 @@ function validDecisionTrace(value) {
|
|
|
40
41
|
// sha. La salida explícita existe porque hay tareas que no producen commit —abrir una fila en
|
|
41
42
|
// HUMAN_ACTIONS, registrar un informe—, y forzarlas a llenar el campo produce un sha inventado, que es
|
|
42
43
|
// peor que la ausencia: parece evidencia.
|
|
44
|
+
// Se corta en cada `|`, y en un `;` sólo cuando detrás viene un sha: el `;` aparece también dentro del
|
|
45
|
+
// paréntesis final —`(api@main; sin footer Task:)`— y cortar ahí convertiría una nota en un commit que
|
|
46
|
+
// falta. Cada tramo responde por sí mismo; validar sólo el primero dejaba pasar la mitad sin artefacto.
|
|
47
|
+
const commitParts = (value) => String(value || '').trim().split(/\s*(?:\||;(?=\s*[0-9a-f]{7,40}\s))\s*/)
|
|
48
|
+
|
|
43
49
|
function validCommitTrace(value) {
|
|
44
|
-
|
|
45
|
-
// paréntesis final —`(api@main; sin footer Task:)`— y cortar ahí convertiría una nota en un commit que
|
|
46
|
-
// falta. Cada tramo responde por sí mismo; validar sólo el primero dejaba pasar la mitad sin artefacto.
|
|
47
|
-
return String(value || '').trim().split(/\s*(?:\||;(?=\s*[0-9a-f]{7,40}\s))\s*/)
|
|
48
|
-
.every((part) => COMMIT_TRACE.test(part.trim()))
|
|
50
|
+
return commitParts(value).every((part) => COMMIT_TRACE.test(part.trim()))
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
// Los criterios que la evidencia realmente rastrea. `n/a — razón` no rastrea ninguno a propósito: es
|
|
@@ -118,8 +120,8 @@ function doneEntryErrors(entry, epics = []) {
|
|
|
118
120
|
// Un carril declara **cuánta ceremonia** merecía la tarea; `n/a` en `review:` dice que la revisión no
|
|
119
121
|
// corrió. `express` es el único que no convoca revisor, así que en los otros tres esa combinación es la
|
|
120
122
|
// ADR incumplida, escrita en el propio registro.
|
|
121
|
-
const
|
|
122
|
-
const
|
|
123
|
+
const REVIEWED_LANES = ['directo', 'lite', 'full']
|
|
124
|
+
const NO_REVIEW = /^n\/a\b/i
|
|
123
125
|
|
|
124
126
|
// Lo que el registro puede decir sobre la ceremonia, y lo que todavía no. Los dos campos avisan en vez de
|
|
125
127
|
// fallar por lo que dice `doneEntryErrors`, y cuentan en vez de listar porque al principio son todas: lo
|
|
@@ -134,22 +136,22 @@ const SIN_REVISION = /^n\/a\b/i
|
|
|
134
136
|
// completo, pero eso lo sabe el recorrido y no la entrada. Avisar sobre lo que hay que deducir es lo que
|
|
135
137
|
// llena de ruido un aviso que después nadie mira.
|
|
136
138
|
function doneCeremonyWarnings(done, adopted = new Set()) {
|
|
137
|
-
const
|
|
139
|
+
const own = done.entries.filter((entry) => !adopted.has(entry.slug))
|
|
138
140
|
const warnings = []
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
141
|
-
warnings.push(`planning/done: ${
|
|
141
|
+
const withoutLane = own.filter((entry) => !entry.lane)
|
|
142
|
+
if (withoutLane.length) {
|
|
143
|
+
warnings.push(`planning/done: ${withoutLane.length} entrada(s) sin lane:, así que no se puede comprobar `
|
|
142
144
|
+ 'sobre el registro que la ceremonia que recibieron fue la que su superficie pedía (OPS-006)')
|
|
143
145
|
}
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
warnings.push(`planning/done: ${
|
|
146
|
+
const withoutReview = own.filter((entry) => !entry.review)
|
|
147
|
+
if (withoutReview.length) {
|
|
148
|
+
warnings.push(`planning/done: ${withoutReview.length} entrada(s) sin review:, que es la dimensión con la `
|
|
147
149
|
+ 'que OPS-006 dice que se mide si el carril elegido fue el correcto')
|
|
148
150
|
}
|
|
149
|
-
const
|
|
150
|
-
&& entry.review &&
|
|
151
|
-
if (
|
|
152
|
-
warnings.push(`planning/done: ${
|
|
151
|
+
const skipped = own.filter((entry) => REVIEWED_LANES.includes(entry.lane)
|
|
152
|
+
&& entry.review && NO_REVIEW.test(entry.review))
|
|
153
|
+
if (skipped.length) {
|
|
154
|
+
warnings.push(`planning/done: ${skipped.map((entry) => entry.slug).join(', ')} declara(n) un carril `
|
|
153
155
|
+ 'que convoca revisor y una revisión que no corrió: el carril reduce ceremonia, nunca evidencia')
|
|
154
156
|
}
|
|
155
157
|
return warnings
|
|
@@ -165,14 +167,6 @@ const POST_VERIFY = [
|
|
|
165
167
|
[/\bel reclamo\b|\bclaims\//i, 'el reclamo'],
|
|
166
168
|
]
|
|
167
169
|
|
|
168
|
-
// La salida explícita, con la forma que el repositorio ya usa dos veces: `(sin partir: …)` para el umbral
|
|
169
|
-
// de R17 y `n/a — razón` para `tests:` y `commit:`. Acá vale lo mismo que allá —«como lleva su razón
|
|
170
|
-
// escrita se lee en el propio artefacto sin que nadie la cruce»— y por eso no se intenta adivinar si la
|
|
171
|
-
// prosa excluye a Verify. Adivinarlo es lo que no se puede: la única aceptación real que nombra el commit
|
|
172
|
-
// lo hace justamente para decir que no es condición de Verify, y cualquier lista de frases que la
|
|
173
|
-
// reconociera enseñaría a escribir esa frase exacta para silenciar el aviso.
|
|
174
|
-
const OUT_OF_VERIFY = /\(fuera de verify:\s*[^)]+\)/i
|
|
175
|
-
|
|
176
170
|
// Una condición de aceptación que nombra el registro en vez del producto no se puede cumplir nunca: se
|
|
177
171
|
// comprueba en Verify, que corre antes que Commit y que Done. El recorrido lo detecta —y hace bien—, pero
|
|
178
172
|
// recién ahí: en la corrida que originó esto fueron 1,2 M de tokens y once agentes para terminar con el
|
|
@@ -194,11 +188,11 @@ function unverifiableAcceptance(milestones = []) {
|
|
|
194
188
|
const warnings = []
|
|
195
189
|
for (const milestone of milestones) {
|
|
196
190
|
for (const task of milestone.tasks || []) {
|
|
197
|
-
for (const condition of
|
|
198
|
-
if (
|
|
199
|
-
const
|
|
200
|
-
if (!
|
|
201
|
-
warnings.push(`BACKLOG ${task.slug}: una condición nombra ${
|
|
191
|
+
for (const condition of acceptanceConditions(task.acceptance)) {
|
|
192
|
+
if (OUT_OF_VERIFY.test(condition)) continue
|
|
193
|
+
const names = POST_VERIFY.filter(([pattern]) => pattern.test(condition))
|
|
194
|
+
if (!names.length) continue
|
|
195
|
+
warnings.push(`BACKLOG ${task.slug}: una condición nombra ${names.map(([, what]) => what).join(', ')}`
|
|
202
196
|
+ ', que existe después de Verify, así que no se puede comprobar cuando se la comprueba. Eso va '
|
|
203
197
|
+ 'en tests:, qa: o commit: de su entrada de DONE, que ya lo exigen; si de verdad va acá, '
|
|
204
198
|
+ 'declaralo con "(fuera de verify: <razón>)"')
|
|
@@ -208,6 +202,44 @@ function unverifiableAcceptance(milestones = []) {
|
|
|
208
202
|
return warnings
|
|
209
203
|
}
|
|
210
204
|
|
|
205
|
+
// `tests: n/a` dice que la tarea no produjo nada que se ejecute, y quien lo escribe es el mismo que quiere
|
|
206
|
+
// cerrar: desde el 189 el recorrido lo emite cuando Verify declara `no-surface`, que es la palabra de un
|
|
207
|
+
// modelo. Lo que la vuelve creíble es mirar qué tocó el commit, y eso no lo decide nadie. Se juzga el n/a
|
|
208
|
+
// entero y no el mixto: en una tarea que rastrea algún criterio con su prueba, el código del commit es el
|
|
209
|
+
// de esos criterios y no dice nada del que se declaró sin superficie.
|
|
210
|
+
//
|
|
211
|
+
// `filesOf` devuelve los archivos de un sha, o null si ningún repositorio lo conoce. Ahí calla, igual que
|
|
212
|
+
// el resto de lo que pregunta a git desde `check`: un sha que no se encuentra no es superficie, y avisarlo
|
|
213
|
+
// sería inventar el hallazgo. Lo adoptado queda afuera por lo mismo que en el resto de DONE: se escribió
|
|
214
|
+
// antes de que el contrato lo pidiera.
|
|
215
|
+
//
|
|
216
|
+
// Y rige desde que existe. Una entrada cerrada antes con `tests: n/a` sobre código no violó nada que se le
|
|
217
|
+
// hubiera pedido, y frenarla ahora pondría `check` en rojo con el `upgrade` —y con él al guard que lo corre
|
|
218
|
+
// antes de cada commit— por algo que nadie puede corregir sin reescribir evidencia. Es la misma razón por
|
|
219
|
+
// la que se exime lo adoptado, aplicada a lo que ya estaba cerrado cuando la regla llegó.
|
|
220
|
+
const NOT_APPLICABLE = /^n\/a\s*[—-]/i
|
|
221
|
+
const SURFACE_SINCE = '2026-09-24'
|
|
222
|
+
|
|
223
|
+
function surfaceWithoutTests(entries, filesOf, adopted = new Set()) {
|
|
224
|
+
const errors = []
|
|
225
|
+
for (const entry of entries) {
|
|
226
|
+
const traces = splitTraces(entry.tests)
|
|
227
|
+
if (adopted.has(entry.slug) || !traces.length || !traces.every((one) => NOT_APPLICABLE.test(one))) continue
|
|
228
|
+
if (!(String(entry.fecha || '') >= SURFACE_SINCE)) continue
|
|
229
|
+
for (const sha of commitParts(entry.commit).map((part) => (part.match(/^([0-9a-f]{7,40})\s/) || [])[1])) {
|
|
230
|
+
if (!sha) continue
|
|
231
|
+
const surface = (filesOf(sha) || [])
|
|
232
|
+
.filter((file) => !NON_EXECUTABLE.includes(path.extname(file).toLowerCase()))
|
|
233
|
+
if (!surface.length) continue
|
|
234
|
+
const shown = surface.length > 5 ? [...surface.slice(0, 5), `y ${surface.length - 5} más`] : surface
|
|
235
|
+
errors.push(`${entry.source} ${entry.slug}: tests: n/a dice que no hay superficie ejecutable y el commit `
|
|
236
|
+
+ `${sha} toca ${shown.join(', ')}; sólo ${NON_EXECUTABLE.join(', ')} cuentan como no ejecutables, `
|
|
237
|
+
+ 'así que esos criterios se rastrean con su prueba')
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return errors
|
|
241
|
+
}
|
|
242
|
+
|
|
211
243
|
function duplicates(values) {
|
|
212
244
|
return [...new Set(values.filter((value, index) => values.indexOf(value) !== index))]
|
|
213
245
|
}
|
|
@@ -260,22 +292,22 @@ function dependencyErrors(milestones, done) {
|
|
|
260
292
|
// Recorrido en profundidad con el camino a cuestas: al reencontrar un slug que sigue en el camino,
|
|
261
293
|
// ese camino **es** el ciclo, y nombrarlo entero es lo que lo hace reparable — decir sólo que hay uno
|
|
262
294
|
// deja el trabajo de encontrarlo del lado de quien lee.
|
|
263
|
-
const
|
|
264
|
-
const
|
|
265
|
-
if (
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
268
|
-
const
|
|
269
|
-
errors.push(`BACKLOG: ciclo de dependencias ${
|
|
295
|
+
const state = new Map()
|
|
296
|
+
const visit = (slug, trail) => {
|
|
297
|
+
if (state.get(slug) === 'listo') return
|
|
298
|
+
const from = trail.indexOf(slug)
|
|
299
|
+
if (from >= 0) {
|
|
300
|
+
const cycle = [...trail.slice(from), slug]
|
|
301
|
+
errors.push(`BACKLOG: ciclo de dependencias ${cycle.join(' → ')}`)
|
|
270
302
|
return
|
|
271
303
|
}
|
|
272
304
|
for (const dep of queued.get(slug) || []) {
|
|
273
305
|
// La que se depende a sí misma ya tiene su error, más claro que un ciclo de un solo paso.
|
|
274
|
-
if (dep !== slug && queued.has(dep))
|
|
306
|
+
if (dep !== slug && queued.has(dep)) visit(dep, [...trail, slug])
|
|
275
307
|
}
|
|
276
|
-
|
|
308
|
+
state.set(slug, 'listo')
|
|
277
309
|
}
|
|
278
|
-
for (const slug of queued.keys())
|
|
310
|
+
for (const slug of queued.keys()) visit(slug, [])
|
|
279
311
|
return [...new Set(errors)]
|
|
280
312
|
}
|
|
281
313
|
|
|
@@ -397,10 +429,10 @@ function validateState({
|
|
|
397
429
|
// ofreciendo. `**slug: de qué se trata**` es la que sale natural, porque esta tabla la lee una
|
|
398
430
|
// persona. Sin esto la ausencia no deja rastro, que es la forma de R15 aplicada a un mecanismo.
|
|
399
431
|
if (backlogSlugs.has(row.task)) continue
|
|
400
|
-
const
|
|
401
|
-
if (
|
|
402
|
-
errors.push(`HUMAN_ACTIONS: la fila "${row.task}" nombra a ${
|
|
403
|
-
+ `motor bloquea por la primera columna exacta. Dejá "${
|
|
432
|
+
const near = [...backlogSlugs].find((slug) => new RegExp(`\\b${slug}\\b`).test(row.task))
|
|
433
|
+
if (near) {
|
|
434
|
+
errors.push(`HUMAN_ACTIONS: la fila "${row.task}" nombra a ${near} y no bloquea nada, porque el `
|
|
435
|
+
+ `motor bloquea por la primera columna exacta. Dejá "${near}" sola ahí y contá el resto en la `
|
|
404
436
|
+ 'acción, o nombrá la épica o el recorrido si lo que se frena no es esa tarea')
|
|
405
437
|
}
|
|
406
438
|
}
|
|
@@ -435,6 +467,7 @@ module.exports = {
|
|
|
435
467
|
doneEntryErrors,
|
|
436
468
|
doneCeremonyWarnings,
|
|
437
469
|
unverifiableAcceptance,
|
|
470
|
+
surfaceWithoutTests,
|
|
438
471
|
validCommitTrace,
|
|
439
472
|
validDecisionTrace,
|
|
440
473
|
validTestTrace,
|