@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.
Files changed (93) hide show
  1. package/CHANGELOG.md +162 -0
  2. package/README.md +1 -0
  3. package/agents/roles/system/accounting-specialist/learning/AUTOMATION.md +2 -2
  4. package/agents/roles/system/ai-governance-lead/learning/AUTOMATION.md +1 -1
  5. package/agents/roles/system/ai-product-manager/learning/AUTOMATION.md +1 -1
  6. package/agents/roles/system/analytics-engineer/learning/AUTOMATION.md +1 -1
  7. package/agents/roles/system/backend-engineer/learning/AUTOMATION.md +2 -2
  8. package/agents/roles/system/business-strategist/learning/AUTOMATION.md +2 -2
  9. package/agents/roles/system/cloud-architect/learning/AUTOMATION.md +1 -1
  10. package/agents/roles/system/community-manager/learning/AUTOMATION.md +1 -1
  11. package/agents/roles/system/content-specialist/learning/AUTOMATION.md +2 -2
  12. package/agents/roles/system/customer-success-manager/learning/AUTOMATION.md +2 -2
  13. package/agents/roles/system/customer-support-specialist/learning/AUTOMATION.md +2 -2
  14. package/agents/roles/system/data-analyst/learning/AUTOMATION.md +2 -2
  15. package/agents/roles/system/data-engineer/learning/AUTOMATION.md +1 -1
  16. package/agents/roles/system/data-governance-steward/learning/AUTOMATION.md +2 -2
  17. package/agents/roles/system/data-scientist/learning/AUTOMATION.md +1 -1
  18. package/agents/roles/system/database-administrator/learning/AUTOMATION.md +1 -1
  19. package/agents/roles/system/developer-relations-engineer/learning/AUTOMATION.md +1 -1
  20. package/agents/roles/system/devops-engineer/learning/AUTOMATION.md +2 -2
  21. package/agents/roles/system/engineering-manager/learning/AUTOMATION.md +1 -1
  22. package/agents/roles/system/financial-controller/learning/AUTOMATION.md +2 -2
  23. package/agents/roles/system/finops-engineer/learning/AUTOMATION.md +2 -2
  24. package/agents/roles/system/fraud-risk-analyst/learning/AUTOMATION.md +2 -2
  25. package/agents/roles/system/frontend-engineer/learning/AUTOMATION.md +2 -2
  26. package/agents/roles/system/growth-marketer/learning/AUTOMATION.md +2 -2
  27. package/agents/roles/system/implementation-manager/learning/AUTOMATION.md +1 -1
  28. package/agents/roles/system/integrations-engineer/learning/AUTOMATION.md +3 -3
  29. package/agents/roles/system/kyc-aml-specialist/learning/AUTOMATION.md +3 -3
  30. package/agents/roles/system/legal-counsel/learning/AUTOMATION.md +1 -1
  31. package/agents/roles/system/logistics-operations-manager/learning/AUTOMATION.md +2 -2
  32. package/agents/roles/system/machine-learning-engineer/learning/AUTOMATION.md +1 -1
  33. package/agents/roles/system/mlops-engineer/learning/AUTOMATION.md +1 -1
  34. package/agents/roles/system/mobile-engineer/learning/AUTOMATION.md +2 -2
  35. package/agents/roles/system/partnerships-manager/learning/AUTOMATION.md +1 -1
  36. package/agents/roles/system/people-operations-manager/learning/AUTOMATION.md +1 -1
  37. package/agents/roles/system/privacy-compliance-specialist/learning/AUTOMATION.md +2 -2
  38. package/agents/roles/system/procurement-manager/learning/AUTOMATION.md +1 -1
  39. package/agents/roles/system/product-manager/learning/AUTOMATION.md +3 -3
  40. package/agents/roles/system/product-marketing-manager/learning/AUTOMATION.md +2 -2
  41. package/agents/roles/system/project-manager/learning/AUTOMATION.md +1 -1
  42. package/agents/roles/system/qa-engineer/learning/AUTOMATION.md +2 -2
  43. package/agents/roles/system/release-manager/learning/AUTOMATION.md +1 -1
  44. package/agents/roles/system/sales-representative/learning/AUTOMATION.md +2 -2
  45. package/agents/roles/system/security-engineer/learning/AUTOMATION.md +2 -2
  46. package/agents/roles/system/site-reliability-engineer/learning/AUTOMATION.md +2 -2
  47. package/agents/roles/system/software-architect/learning/AUTOMATION.md +2 -2
  48. package/agents/roles/system/solutions-engineer/learning/AUTOMATION.md +1 -1
  49. package/agents/roles/system/tech-lead/learning/AUTOMATION.md +2 -2
  50. package/agents/roles/system/technical-program-manager/learning/AUTOMATION.md +1 -1
  51. package/agents/roles/system/technical-writer/learning/AUTOMATION.md +1 -1
  52. package/agents/roles/system/treasury-analyst/learning/AUTOMATION.md +2 -2
  53. package/agents/roles/system/ui-designer/learning/AUTOMATION.md +3 -3
  54. package/agents/roles/system/user-researcher/learning/AUTOMATION.md +3 -3
  55. package/agents/roles/system/ux-designer/learning/AUTOMATION.md +3 -3
  56. package/automatization/hooks/README.md +10 -5
  57. package/automatization/runners/antigravity/README.md +4 -1
  58. package/automatization/runners/antigravity/hook.js +84 -22
  59. package/automatization/runners/antigravity/manifest.json +1 -0
  60. package/automatization/shared/acceptance.js +26 -0
  61. package/automatization/workflows/agent-propose.js +2 -2
  62. package/automatization/workflows/autobuild.js +121 -38
  63. package/engine/agents/learning-files.js +18 -0
  64. package/engine/agents/learning-seal.js +24 -12
  65. package/engine/agents/learning-sources.js +12 -10
  66. package/engine/agents/learning.js +29 -7
  67. package/engine/automation/index.js +11 -2
  68. package/engine/automation/registration.js +89 -0
  69. package/engine/cli/args.js +2 -2
  70. package/engine/cli/catalog.js +12 -12
  71. package/engine/cli/ops.js +2 -1
  72. package/engine/cli/validate.js +3 -0
  73. package/engine/cli/wiring.js +1 -1
  74. package/engine/config/validate.js +30 -11
  75. package/engine/core/migrations.js +275 -0
  76. package/engine/core/repos.js +18 -4
  77. package/engine/hooks/approval.js +38 -7
  78. package/engine/hooks/chat.js +97 -27
  79. package/engine/hooks/files.js +6 -104
  80. package/engine/hooks/input.js +67 -11
  81. package/engine/hooks/migrations.js +93 -0
  82. package/engine/hooks/push.js +8 -5
  83. package/engine/hooks/run.js +10 -5
  84. package/engine/hooks/self-approval.js +2 -2
  85. package/engine/hooks/shell.js +34 -0
  86. package/engine/hooks/verify.js +79 -22
  87. package/engine/planning/acceptance.js +18 -0
  88. package/engine/planning/contracts.js +78 -45
  89. package/engine/schemas/ops-config.schema.json +9 -0
  90. package/package.json +1 -1
  91. package/template/AGENTS.md +8 -5
  92. package/template/Makefile +1 -1
  93. package/template/planning/PROTOCOL.md +6 -2
@@ -0,0 +1,89 @@
1
+ 'use strict'
2
+
3
+ // La copia que el runner ejecuta, cuando no es la del workspace. `agy` corre la que registra
4
+ // `agy plugin install`, una por usuario y no por proyecto, así que el plugin del workspace puede estar
5
+ // perfecto mientras el runner ejecuta el de otro proyecto, de otra versión o roto. `doctor` sondeaba sólo
6
+ // el del workspace y decía «operativo» con cada llamada del runner fallando (caso 201).
7
+ //
8
+ // El runner declara dónde vive esa copia —`activation.registered` en su manifiesto—, y sin esa
9
+ // declaración esto no dice nada: no hay copia aparte que mirar.
10
+
11
+ const fs = require('node:fs')
12
+ const os = require('node:os')
13
+ const path = require('node:path')
14
+ const { spawnSync } = require('node:child_process')
15
+
16
+ function registeredCopy(runner) {
17
+ const declared = runner.activation && runner.activation.registered
18
+ return declared ? declared.replace(/^~(?=\/|$)/, os.homedir()) : ''
19
+ }
20
+
21
+ // Los archivos del plugin, relativos a su carpeta: los que el adaptador entrega bajo ella, más su
22
+ // configuración de hooks.
23
+ function pluginFiles(paths, runner, pluginDir) {
24
+ const targets = [...(runner.artifacts || []).map((item) => path.resolve(paths.install, item.target)),
25
+ paths.configTarget]
26
+ return [...new Set(targets)]
27
+ .filter((file) => file.startsWith(`${pluginDir}${path.sep}`))
28
+ .map((file) => path.relative(pluginDir, file))
29
+ }
30
+
31
+ // Lanza cada comando de la configuración registrada como lo lanza el runner: el texto literal, desde la
32
+ // carpeta del plugin. Así se ve el wiring que no resuelve, que ejecutando el puente directo no aparece.
33
+ // Con tope: la copia registrada puede ser vieja o estar rota, y una que no contesta colgaba a `doctor`. Diez
34
+ // segundos sobran para una copia sana —arranca y contesta en menos de uno— y con una que no contesta ya
35
+ // alcanza para el diagnóstico, así que el sondeo corta ahí en vez de esperar el tope una vez por evento.
36
+ const LAUNCH_LIMIT_MS = 10000
37
+
38
+ function launch(dir, event, command) {
39
+ const payload = event === 'pre-shell' ? { toolCall: { args: { CommandLine: 'ls' } } } : {}
40
+ const input = JSON.stringify(payload)
41
+ const result = spawnSync('sh', ['-c', command],
42
+ { cwd: dir, input, encoding: 'utf8', timeout: LAUNCH_LIMIT_MS, killSignal: 'SIGKILL' })
43
+ if (result.error && result.error.code === 'ETIMEDOUT') return `no respondió en ${LAUNCH_LIMIT_MS / 1000} s`
44
+ let response = {}
45
+ try { response = JSON.parse((result.stdout || '').trim()) } catch { response = {} }
46
+ const healthy = event === 'stop' ? 'stop' : 'allow'
47
+ if (response.decision === healthy && !response.reason) return ''
48
+ const stderr = (result.stderr || '').split('\n').find((line) => /Error|error/.test(line)) || ''
49
+ return response.reason || (response.decision ? `respondió ${response.decision}` : stderr.trim() || 'sin respuesta')
50
+ }
51
+
52
+ // Qué impide que la copia registrada sea esta instalación funcionando. Vacío si el runner no declara una
53
+ // copia aparte o si todavía no hay ninguna registrada —eso lo dice `activated`—.
54
+ function registrationProblems(paths, runner) {
55
+ const dir = registeredCopy(runner)
56
+ if (!dir || !fs.existsSync(dir)) return []
57
+ const bridge = (runner.artifacts || []).find((item) => item.target.endsWith('hook.js'))
58
+ if (!bridge) return []
59
+ const pluginDir = path.dirname(path.resolve(paths.install, bridge.target))
60
+ const differ = pluginFiles(paths, runner, pluginDir).filter((file) => {
61
+ const ours = path.join(pluginDir, file)
62
+ const theirs = path.join(dir, file)
63
+ if (!fs.existsSync(ours)) return false
64
+ return !fs.existsSync(theirs) || !fs.readFileSync(ours).equals(fs.readFileSync(theirs))
65
+ })
66
+ const problems = []
67
+ if (differ.length) {
68
+ const shown = differ.length > 5 ? [...differ.slice(0, 5), `y ${differ.length - 5} más`] : differ
69
+ problems.push(`${runner.command} ejecuta ${dir}, que no es esta instalación: difiere(n) ${shown.join(', ')}. `
70
+ + `Corré desde ${paths.install}: ${runner.activation.hint}`)
71
+ }
72
+ let config = {}
73
+ try { config = JSON.parse(fs.readFileSync(path.join(dir, path.basename(paths.configTarget)), 'utf8')) } catch {
74
+ return problems
75
+ }
76
+ const commands = [...new Set(JSON.stringify(config).match(/"command":"([^"]*hook\.js [a-z-]+)"/g) || [])]
77
+ .map((entry) => entry.slice(11, -1))
78
+ for (const command of commands) {
79
+ const event = command.split(' ').pop()
80
+ const failure = launch(dir, event, command)
81
+ if (failure) {
82
+ problems.push(`la copia registrada no responde a ${event} lanzada como la lanza ${runner.command}: ${failure}`)
83
+ }
84
+ if (/^no respondió/.test(failure)) break
85
+ }
86
+ return problems
87
+ }
88
+
89
+ module.exports = { registrationProblems }
@@ -7,7 +7,7 @@
7
7
  // Banderas que consumen el argumento siguiente: su valor no es un posicional.
8
8
  const VALUED_FLAGS = new Set([
9
9
  '--name', '--mode', '--fixture', '--period', '--record', '--runner', '--integration',
10
- '--task', '--promote', '--hito',
10
+ '--task', '--promote', '--hito', '--reason',
11
11
  ])
12
12
 
13
13
  // Qué acepta cada comando, y a la vez qué comandos existen. Una bandera desconocida se rechaza en vez
@@ -36,7 +36,7 @@ const FLAGS = {
36
36
  integration: ['--fixture'],
37
37
  secrets: [],
38
38
  automation: ['--force'],
39
- learn: ['--flow', '--proposal', '--applied', '--archived', '--period'],
39
+ learn: ['--flow', '--proposal', '--applied', '--archived', '--period', '--reason'],
40
40
  evaluate: ['--cases', '--json', '--bench', '--force', '--record', '--flow'],
41
41
  flow: ['--json'],
42
42
  }
@@ -91,14 +91,14 @@ function learn(agent, cli) {
91
91
  // y lo que se hacía era mergear el PR sin firmar — que deja el documento diciendo «sin aplicar»
92
92
  // para siempre, indistinguible de una que espera trabajo.
93
93
  if (cli.has('--archived')) {
94
- const result = L.archive(opsRoot(), agent, cli.value('--period'), kind)
94
+ const result = L.archive(opsRoot(), agent, cli.value('--period'), kind, cli.value('--reason') || '')
95
95
  const relative = path.relative(opsRoot(), result.file)
96
- // Los dos archivados no son lo mismo y el mensaje lo dice: uno es una decisión —se miró y no
97
- // cambia nada— y el otro es tirar un andamio que nadie llegó a llenar. Afirmar el primero sobre
98
- // el segundo le cuenta a quien archiva que hubo una revisión que no hubo.
99
- const porque = result.blank
100
- ? 'nadie decidió el cambio y el documento quedó con el molde'
101
- : 'se miró y no cambia nada'
96
+ // Los dos archivados no son lo mismo y el mensaje lo dice: uno es una decisión, con su motivo, y
97
+ // el otro es tirar un andamio que nadie llegó a llenar. Afirmar el primero sobre el segundo le
98
+ // cuenta a quien archiva que hubo una revisión que no hubo.
99
+ const porque = result.reason
100
+ ? `descartada — ${result.reason}`
101
+ : 'nadie decidió el cambio y el documento quedó con el molde'
102
102
  return console.log(result.already
103
103
  ? `= ${relative} ya estaba archivada`
104
104
  : `✓ ${relative} queda archivada: ${porque}`)
@@ -110,13 +110,13 @@ function learn(agent, cli) {
110
110
  ? `= ${relative} ya estaba aplicada`
111
111
  : `✓ ${relative} queda aplicada: no se vuelve a aplicar`)
112
112
  }
113
- // Un recorrido no tiene informe semanal: su propuesta se compone de los veredictos de sus propias
114
- // corridas y nunca lee `learning/reports/`. La forma desnuda —la que para un cargo abre el informe
115
- // de la semana— no tiene entonces qué abrir acá, y `prepareReport` lo decía resolviendo con el
113
+ // Un recorrido no tiene informe de investigación: su propuesta se compone de los veredictos de sus
114
+ // propias corridas y nunca lee `learning/reports/`. La forma desnuda —la que para un cargo abre su
115
+ // informe— no tiene entonces qué abrir acá, y `prepareReport` lo decía resolviendo con el
116
116
  // `kind` por defecto: «no existe agents/<tipo>/<slug>/SKILL.md», que manda a crear un cargo que no
117
117
  // falta. Negarse nombrando el comando que sí corresponde es lo que cierra R13.
118
118
  if (kind === 'flow' && !cli.has('--proposal')) {
119
- fail(`${agent} es un recorrido: aprende de sus corridas, no de informes semanales.\n`
119
+ fail(`${agent} es un recorrido: aprende de sus corridas, no de informes de investigación.\n`
120
120
  + ` Abrí la propuesta con "ops learn ${agent} --flow --proposal".`, USAGE)
121
121
  }
122
122
  // `--period` es para consolidar a mano un mes que no es el de hoy. El ciclo automático no lo
@@ -137,7 +137,7 @@ function learn(agent, cli) {
137
137
  if (typeof result.reports === 'number') {
138
138
  console.log(kind === 'flow'
139
139
  ? ` ${result.reports} corrida(s) consolidada(s), ${result.findings} hallazgo(s)`
140
- : ` ${result.reports} informe(s) semanal(es) incluidos`)
140
+ : ` ${result.reports} informe(s) de investigación incluidos`)
141
141
  }
142
142
  // Lo lee quien automatiza el ciclo para no pedir una firma por un documento que no decide nada, y
143
143
  // también quien lo corre a mano: sin esta línea el archivo se ve terminado y no lo está. Se pregunta
package/engine/cli/ops.js CHANGED
@@ -180,7 +180,8 @@ function usage() {
180
180
  ops automation doctor <ops-root> claude|codex|gemini|antigravity
181
181
  ops automation install <ops-root> claude|codex|gemini|antigravity
182
182
  ops automation uninstall <ops-root> claude|codex|gemini|antigravity
183
- ops learn <agent|flow> [--flow] [--proposal [--period <AAAA-MM>]] [--applied|--archived [--period <AAAA-MM>]]
183
+ ops learn <agent|flow> [--flow] [--proposal [--period <AAAA-MM>]] [--applied [--period <AAAA-MM>]]
184
+ ops learn <agent|flow> [--flow] --archived [--period <AAAA-MM>] [--reason <motivo>]
184
185
  ops evaluate <agent|flow> [--flow] [--cases [--json]] [--bench [caso]] [--record [AAAA-MM-DD]]
185
186
  ops agents list [ops-root] [--own|--system] [--json]
186
187
  ops agents fork <cargo> [ops-root]
@@ -26,6 +26,7 @@ const O = require('../core/ownership')
26
26
  const TR = require('../core/trails')
27
27
  const OB = require('../core/onboarding')
28
28
  const C = require('../config/validate')
29
+ const MG = require('../core/migrations')
29
30
  const CP = require('../config/paths')
30
31
  const AG = require('../agents/catalog')
31
32
  const RL = require('../automation/rules')
@@ -62,6 +63,7 @@ function check(dir, cli) {
62
63
  if (!raw.includes('{{')) {
63
64
  errors.push(...C.validateOpsConfig(config))
64
65
  warnings.push(...C.configWarnings(config))
66
+ warnings.push(...MG.coverageWarnings(R.reposFor(path.dirname(configPath), '.'), config))
65
67
  if (Array.isArray(config.workspaceRoots)) {
66
68
  for (const workspace of config.workspaceRoots) {
67
69
  if (workspace && workspace.name && workspace.path
@@ -108,6 +110,7 @@ function check(dir, cli) {
108
110
  // corre en los cuatro carriles, incluidos los que saltean Ready (caso 140).
109
111
  warnings.push(...PC.unverifiableAcceptance(milestones))
110
112
  warnings.push(...R.coverageWarnings(path.resolve(root, '..'), done))
113
+ errors.push(...PC.surfaceWithoutTests(done.entries, R.commitFiles(path.resolve(root, '..')), new Set(adopted)))
111
114
  // Sin `RECURRING.md` no dice una palabra: una instancia que actualiza y no declara trabajo recurrente
112
115
  // no tiene por qué enterarse de que el contrato existe. Vencida avisa y no frena — lo que frena vive
113
116
  // en `HUMAN_ACTIONS.md`, y un aviso que salta siempre se termina apagando.
@@ -283,7 +283,7 @@ function automation(action, rootArg, runnerName, cli) {
283
283
  if (!runner.capabilities.nativeHooks) {
284
284
  console.log(` ${runnerName} no expone hooks nativos; aplica guards como prechecks.`)
285
285
  }
286
- const result = A.doctor(root, runnerName)
286
+ const result = A.doctor(root, runnerName, console, { afterInstall: true })
287
287
  if (result.errors.length) fail(`${runnerName}: instalación incompleta`, REFUSED)
288
288
  console.log(`✓ ${runnerName}: adaptador operativo (${result.warnings.length} advertencia(s))`)
289
289
  return
@@ -1,5 +1,7 @@
1
1
  'use strict'
2
2
 
3
+ const { PATH_SHAPE, EXTENSION_SHAPE } = require('../core/migrations')
4
+
3
5
  const MODES = ['embedded', 'sidecar', 'toolkit']
4
6
 
5
7
  // Campos que existieron y se retiraron. Se nombran en vez de caer en «propiedad desconocida» porque
@@ -69,18 +71,35 @@ function validateMigrations(migrations, errors) {
69
71
  return
70
72
  }
71
73
  for (const key of Object.keys(migrations)) {
72
- if (key !== 'extensions') errors.push(`ops.config.json: migrations.${key} no está permitido`)
73
- }
74
- if (!('extensions' in migrations)) return
75
- const declaradas = migrations.extensions
76
- if (!Array.isArray(declaradas) || !declaradas.length) {
77
- errors.push('ops.config.json: migrations.extensions debe listar al menos una extensión, o no estar')
78
- return
74
+ if (!['extensions', 'paths'].includes(key)) errors.push(`ops.config.json: migrations.${key} no está permitido`)
75
+ }
76
+ if ('extensions' in migrations) {
77
+ const declared = migrations.extensions
78
+ if (!Array.isArray(declared) || !declared.length) {
79
+ errors.push('ops.config.json: migrations.extensions debe listar al menos una extensión, o no estar')
80
+ } else {
81
+ for (const one of declared) {
82
+ if (typeof one !== 'string' || !EXTENSION_SHAPE.test(one)) {
83
+ errors.push(`ops.config.json: migrations.extensions "${one}" debe ser la extensión sin el punto `
84
+ + 'y en minúscula, como "sql" o "ts"')
85
+ }
86
+ }
87
+ }
79
88
  }
80
- for (const one of declaradas) {
81
- if (typeof one !== 'string' || !/^[a-z0-9]+$/.test(one)) {
82
- errors.push(`ops.config.json: migrations.extensions "${one}" debe ser la extensión sin el punto `
83
- + 'y en minúscula, como "sql" o "ts"')
89
+ // Las carpetas, por lo mismo que las extensiones y con una razón más: el guard las busca dentro de la
90
+ // ruta que recibe, que puede ser relativa, así que una absoluta o una que sale con `..` no nombraría
91
+ // nada que pudiera encontrar (caso 196).
92
+ if ('paths' in migrations) {
93
+ const paths = migrations.paths
94
+ if (!Array.isArray(paths) || !paths.length) {
95
+ errors.push('ops.config.json: migrations.paths debe listar al menos una carpeta, o no estar')
96
+ return
97
+ }
98
+ for (const one of paths) {
99
+ if (typeof one !== 'string' || !PATH_SHAPE.test(one)) {
100
+ errors.push(`ops.config.json: migrations.paths ${JSON.stringify(one)} debe ser una carpeta relativa, `
101
+ + 'sin barra al principio ni al final y sin «.» ni «..», como "migrations" o "alembic/versions"')
102
+ }
84
103
  }
85
104
  }
86
105
  }
@@ -0,0 +1,275 @@
1
+ 'use strict'
2
+
3
+ // Qué es una migración para un proyecto, qué parte de ella aplica y qué cuenta como destruir. Lo preguntan
4
+ // el guard `migrations`, que juzga cada escritura, y `check`, que mira si lo declarado alcanza a algún
5
+ // archivo; la respuesta tiene que ser la misma en los dos, y por eso vive una sola vez acá.
6
+
7
+ const path = require('node:path')
8
+ const { spawnSync } = require('node:child_process')
9
+
10
+ // Sin esto el guard sólo veía `.sql`, así que en TypeORM, Prisma, Django, Rails o Alembic no miraba nada:
11
+ // ni frenaba el SQL destructivo, ni protegía una migración existente de ser reescrita. Y no lo decía —
12
+ // aparecía cableado y en verde—. Medido en una instancia real: 64 migraciones `.sql` cubiertas y **409
13
+ // TypeORM `.ts` invisibles** (caso 077).
14
+ //
15
+ // No se amplía el default a `.ts`/`.py`/`.rb` por su cuenta: eso reintroduciría el falso positivo del
16
+ // caso 039 —un archivo de lenguaje que menciona `DROP TABLE` en un comentario o en un string— por otra
17
+ // puerta. Declararlo es opt-in porque el que sabe si sus migraciones son de lenguaje es el proyecto, y
18
+ // porque así el costo lo elige quien lo paga.
19
+ const DEFAULT_EXTENSIONS = ['sql']
20
+
21
+ // Las carpetas se declaran por lo mismo que las extensiones: ninguna lista del motor alcanza a Alembic,
22
+ // cuya carpeta es el argumento de `alembic init` y puede moverse con `version_locations` (caso 196). El
23
+ // default son los tres nombres que el motor usaba antes, así que quien no declara nada no ve diferencia.
24
+ const DEFAULT_PATHS = ['migrations', 'migration', 'migrate']
25
+
26
+ // Una carpeta relativa, de segmentos que no empiezan con punto: así no hay `..`, `.` ni ruta absoluta, y
27
+ // lo que entra en la expresión regular no trae metacaracteres salvo el punto, que se escapa al usarla.
28
+ const PATH_SHAPE = /^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$/
29
+ const EXTENSION_SHAPE = /^[a-z0-9]+$/
30
+
31
+ // Lo inválido no se descarta en silencio: descartarlo dejaría al proyecto creyendo que declaró una
32
+ // cobertura que no tiene. Lo valida `validateOpsConfig`, y acá se ignora lo que no pasa ese filtro porque
33
+ // el guard no es el lugar donde se enseña a escribir la configuración.
34
+ function declared(config, key, shape, fallback) {
35
+ const value = ((config && config.migrations) || {})[key]
36
+ const usable = (Array.isArray(value) ? value : []).filter((one) => typeof one === 'string' && shape.test(one))
37
+ return usable.length ? usable : fallback
38
+ }
39
+
40
+ function pattern(config) {
41
+ const extensions = declared(config, 'extensions', EXTENSION_SHAPE, DEFAULT_EXTENSIONS)
42
+ const folders = declared(config, 'paths', PATH_SHAPE, DEFAULT_PATHS).map((one) => one.replace(/\./g, '\\.'))
43
+ return new RegExp(`(?:^|/)(?:${folders.join('|')})/.*\\.(?:${extensions.join('|')})$`, 'i')
44
+ }
45
+
46
+ // Dónde empieza la reversión, por formato. Cada marcador es el que su herramienta reconoce y no uno
47
+ // parecido, porque partir donde la herramienta no parte deja sin juzgar algo que sí corre:
48
+ //
49
+ // - goose (`internal/sqlparser/parser.go` de pressly/goose, `main`): la línea empieza con `--` sin
50
+ // espacio antes, y la anotación se compara con `strings.EqualFold`, así que `--+goose down` vale.
51
+ // - dbmate (`pkg/dbmate/migration.go` de amacneil/dbmate, `main`): `(?m)^--\s*migrate:down(\s*$|\s+\S+)`,
52
+ // sensible a mayúsculas.
53
+ //
54
+ // El `Up` también se reconoce porque devuelve al lado que aplica: lo que venga después de él se juzga.
55
+ const SQL_MARKERS = [
56
+ { up: /^--\s*\+goose\s*up\s*$/i, down: /^--\s*\+goose\s*down\s*$/i, label: '-- +goose Down' },
57
+ { up: /^--\s*migrate:up(?:\s*$|\s+\S+)/, down: /^--\s*migrate:down(?:\s*$|\s+\S+)/, label: '-- migrate:down' },
58
+ ]
59
+
60
+ // El método de reversión de cada herramienta de lenguaje: `downgrade()` de Alembic, `down` de Rails
61
+ // (también el `self.down` de las versiones viejas), `down()` de TypeORM y `exports.down` o `export function
62
+ // down` de Knex. Se lo reconoce al principio de la línea, así que una llamada a `this.down(...)` dentro de
63
+ // `up` no cuenta.
64
+ const LANGUAGE_HEADERS = [
65
+ { header: /^(\s*)(?:async\s+)?def\s+downgrade\s*\(/, label: 'downgrade()' },
66
+ { header: /^(\s*)def\s+(?:self\.)?down\b/, label: 'down' },
67
+ { header: /^(\s*)(?:(?:public|protected|private)\s+)?(?:async\s+)?down\s*\(/, label: 'down()' },
68
+ { header: /^(\s*)(?:module\.)?exports\.down\s*=/, label: 'exports.down' },
69
+ { header: /^(\s*)export\s+(?:async\s+)?function\s+down\s*\(|^(\s*)export\s+const\s+down\s*=/, label: 'down()' },
70
+ ]
71
+
72
+ // Las herramientas golang-migrate y sqlx guardan la reversión en otro archivo, `{version}_{title}.down.{extension}`
73
+ // (`MIGRATIONS.md` de golang-migrate/migrate), y ése es reversión entera.
74
+ const DOWN_FILE = /\.down\.[^./]+$/i
75
+
76
+ const indent = (line) => line.match(/^\s*/)[0].length
77
+
78
+ // Qué líneas aplican: una lista de booleanos, uno por línea, y el marcador que parte. `null` si no hay nada
79
+ // que partir, para que quien pregunta juzgue el texto entero: sin marcador reconocido se degrada al
80
+ // comportamiento de antes en vez de fallar abierto. Los marcadores son comentarios y no aplican.
81
+ function sqlMask(lines) {
82
+ const markers = SQL_MARKERS.filter((one) => lines.some((line) => one.up.test(line) || one.down.test(line)))
83
+ if (!markers.length) return null
84
+ let applies = true
85
+ const mask = lines.map((line) => {
86
+ if (markers.some((one) => one.up.test(line))) {
87
+ applies = true
88
+ return false
89
+ }
90
+ if (markers.some((one) => one.down.test(line))) {
91
+ applies = false
92
+ return false
93
+ }
94
+ return applies
95
+ })
96
+ return { mask, label: markers[0].label }
97
+ }
98
+
99
+ // En una migración de lenguaje el bloque termina donde la indentación vuelve a la del encabezado: la línea
100
+ // que lo cierra —`}`, `end`, `};`— queda del lado que aplica, y da igual porque no destruye nada. Es la
101
+ // forma en que las herramientas generan sus migraciones; lo que se aparta de ella acorta el bloque en vez de
102
+ // alargarlo, así que un archivo con formato raro se juzga de más y no de menos. El borde que queda abierto
103
+ // es escribir `up` en la misma línea que el encabezado de `down`, y eso no lo genera ninguna.
104
+ function languageMask(lines) {
105
+ let label = ''
106
+ let depth = null
107
+ const mask = lines.map((line) => {
108
+ if (depth !== null) {
109
+ if (!line.trim() || indent(line) > depth) return false
110
+ depth = null
111
+ }
112
+ const found = LANGUAGE_HEADERS.find((one) => one.header.test(line))
113
+ if (!found) return true
114
+ label = found.label
115
+ depth = indent(line)
116
+ return false
117
+ })
118
+ return label ? { mask, label } : null
119
+ }
120
+
121
+ function applyMask(file, lines) {
122
+ if (DOWN_FILE.test(file)) return { mask: lines.map(() => false), label: path.basename(file) }
123
+ return /\.sql$/i.test(file) ? sqlMask(lines) : languageMask(lines)
124
+ }
125
+
126
+ // Lo que hay que juzgar de una escritura: el texto de las líneas que aplican y el marcador que partió, o
127
+ // el texto entero sin marcador si no había nada que partir.
128
+ //
129
+ // En un `Edit` el guard no ve el archivo, sólo el fragmento. Se reconstruye el archivo como va a quedar
130
+ // —`old` reemplazado en `disk`— y se juzga la parte del fragmento que cae del lado que aplica, así que un
131
+ // fragmento que mueve un marcador se lee en su lugar y no suelto. Sin `disk` o sin `old` adentro se juzga
132
+ // el fragmento entero, como antes.
133
+ function judged(file, text, edit = {}) {
134
+ const { disk, old, all } = edit
135
+ let result = String(text)
136
+ let spans = [[0, result.length]]
137
+ if (old && typeof disk === 'string' && disk.includes(old)) {
138
+ const at = disk.indexOf(old)
139
+ const pieces = all ? disk.split(old) : [disk.slice(0, at), disk.slice(at + old.length)]
140
+ result = pieces[0]
141
+ spans = []
142
+ for (const piece of pieces.slice(1)) {
143
+ spans.push([result.length, result.length + String(text).length])
144
+ result += String(text) + piece
145
+ }
146
+ } else if (old) return { text: String(text), label: '' }
147
+ const lines = result.split('\n')
148
+ const split = applyMask(file, lines.map((line) => line.replace(/\r$/, '')))
149
+ if (!split && spans.length === 1 && spans[0][0] === 0 && spans[0][1] === result.length) {
150
+ return { text: result, label: '' }
151
+ }
152
+ const kept = []
153
+ let at = 0
154
+ lines.forEach((line, index) => {
155
+ const [from, to] = [at, at + line.length]
156
+ at = to + 1
157
+ if (split && !split.mask[index]) return
158
+ for (const [start, end] of spans) {
159
+ if (Math.max(start, from) <= Math.min(end, to)) {
160
+ kept.push(result.slice(Math.max(start, from), Math.min(end, to)))
161
+ }
162
+ }
163
+ })
164
+ return { text: kept.join('\n'), label: split ? split.label : '' }
165
+ }
166
+
167
+ // Las secciones de un `apply_patch`, por archivo: la línea `*** Add|Update|Delete File: <ruta>` y lo que
168
+ // sigue hasta la cabecera del próximo archivo o `*** End Patch`. Cada línea conserva su prefijo —`+`
169
+ // agregada, `-` quitada, ` ` contexto— porque de él depende qué se juzga. `@@`, `*** Move to:` y
170
+ // `*** End of File` son del formato y no contenido, y no cortan la sección: cortarla ahí dejaba sin juzgar
171
+ // lo que venía después, y pasaba lo que el guard viejo, que juzgaba el sobre entero, frenaba.
172
+ function patchSections(patch) {
173
+ const sections = new Map()
174
+ let current = null
175
+ for (const line of String(patch).split('\n')) {
176
+ const header = line.match(/^\*\*\* (Add|Update|Delete) File:\s*(.+)$/)
177
+ if (header) {
178
+ current = { kind: header[1].toLowerCase(), lines: [] }
179
+ sections.set(header[2].trim(), current)
180
+ } else if (/^\*\*\* End Patch\s*$/.test(line)) current = null
181
+ else if (current && !line.startsWith('@@') && !line.startsWith('*** ')) {
182
+ current.lines.push({ op: line[0] || ' ', text: line.slice(1) })
183
+ }
184
+ }
185
+ return sections
186
+ }
187
+
188
+ // Lo que hay que juzgar de un archivo del parche, con el mismo contrato que `judged` (caso 199). Un archivo
189
+ // nuevo es su contenido entero sin el prefijo, y se parte como un `Write`. Una modificación es el lado que va a
190
+ // quedar —contexto y agregado—, partido con lo que el hunk trae, y de él se juzga sólo lo agregado: lo quitado
191
+ // no destruye nada y el contexto ya estaba. Si el hunk no trae ningún marcador, no se sabe de qué lado cae y se
192
+ // juzga todo lo agregado, como antes.
193
+ function judgedPatch(file, section) {
194
+ if (section.kind === 'delete') return { text: '', label: '' }
195
+ if (section.kind === 'add') return judged(file, section.lines.map((line) => line.text).join('\n'))
196
+ const after = section.lines.filter((line) => line.op !== '-')
197
+ const split = applyMask(file, after.map((line) => line.text.replace(/\r$/, '')))
198
+ const kept = after.filter((line, index) => line.op === '+' && (!split || split.mask[index]))
199
+ return { text: kept.map((line) => line.text).join('\n'), label: split ? split.label : '' }
200
+ }
201
+
202
+ // Lo que destruye escrito en SQL. Cada rama cierra su propio límite. Cuando el `\b` estaba al final del
203
+ // grupo se aplicaba a las tres, y la de `delete` termina a propósito en `;`: después de un punto y coma no
204
+ // hay límite de palabra, así que `DELETE FROM pedidos;` —la forma que tiene en cualquier migración— pasaba
205
+ // y sólo frenaba la variante sin punto y coma. `drop column` y `drop constraint` faltaban: pierden datos y
206
+ // garantías igual que `drop table`.
207
+ const DESTRUCTIVE_SQL = new RegExp(
208
+ String.raw`\bdrop\s+(?:table|database|schema|column|constraint)\b` +
209
+ String.raw`|\btruncate\b` +
210
+ String.raw`|\bdelete\s+from\s+\S+\s*(?:;|$)`,
211
+ 'i',
212
+ )
213
+
214
+ // Lo que destruye escrito con la API de la herramienta (caso 193). Son los nombres que la documentación
215
+ // pública de cada una define, comprobados el 2026-09-23 y no más: Alembic (`ops.html`), Rails
216
+ // (`active_record_migrations.md`), TypeORM (`migrations/09-api.md`), Knex (`schema-builder.md`) y Django
217
+ // (`ref/migration-operations`). La lista envejece nombrando: lo que no está sigue pasando, igual que antes.
218
+ // Sensible a mayúsculas porque la API lo es, y así `DeleteModel` no se confunde con prosa.
219
+ const DESTRUCTIVE_API = new RegExp(
220
+ String.raw`\b(?:drop_table|drop_column|remove_columns?|drop_join_table` +
221
+ String.raw`|dropTable|dropTableIfExists|dropColumns?|DeleteModel|RemoveField)\b`,
222
+ )
223
+
224
+ // Qué destruye en `text`, o vacío: el fragmento que lo delata y de qué clase es, para que el mensaje pueda
225
+ // nombrar la sentencia y no sólo el archivo.
226
+ function destructive(text) {
227
+ const sql = String(text).match(DESTRUCTIVE_SQL)
228
+ if (sql) return { kind: 'SQL destructivo', what: sql[0].replace(/\s+/g, ' ').toUpperCase() }
229
+ const api = String(text).match(DESTRUCTIVE_API)
230
+ return api ? { kind: 'un borrado destructivo', what: api[0] } : null
231
+ }
232
+
233
+ // Lo declarado en `migrations` que no alcanza a ningún archivo del producto: una extensión o una carpeta
234
+ // que el guard promete mirar y no mira nada. Es lo que el 077 y el 196 tienen en común —cobertura
235
+ // declarada que no cubre, en silencio—, y va en `check` y no en el guard porque recorre el repositorio y el
236
+ // guard corre en cada escritura (R26).
237
+ //
238
+ // Sólo mira lo que el proyecto declaró: sin declarar, el default no promete nada que alguien haya elegido.
239
+ // Lee `git ls-files` de cada repositorio de las raíces, que es barato y deja afuera lo generado; sin
240
+ // repositorio no dice nada, como el resto de los avisos que preguntan a git.
241
+ function coverageWarnings(repos, config) {
242
+ const migrations = (config && config.migrations) || {}
243
+ const askExtensions = Array.isArray(migrations.extensions)
244
+ const askPaths = Array.isArray(migrations.paths)
245
+ if (!askExtensions && !askPaths) return []
246
+ const files = repos.flatMap((repo) => {
247
+ const listed = spawnSync('git', ['ls-files', '-z'], { cwd: repo, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 })
248
+ return listed.status === 0 ? listed.stdout.split('\0').filter(Boolean) : []
249
+ })
250
+ if (!files.length) return []
251
+ const matcher = pattern(config)
252
+ const covered = files.filter((file) => matcher.test(file))
253
+ const warnings = []
254
+ if (askExtensions) {
255
+ for (const one of declared(config, 'extensions', EXTENSION_SHAPE, [])) {
256
+ if (covered.some((file) => file.toLowerCase().endsWith(`.${one}`))) continue
257
+ warnings.push(`ops.config.json: migrations.extensions "${one}" no alcanza a ningún archivo bajo `
258
+ + `${declared(config, 'paths', PATH_SHAPE, DEFAULT_PATHS).join(', ')}: el guard no mira ninguna `
259
+ + 'migración con esa extensión. Si viven en otra carpeta, declarala en migrations.paths')
260
+ }
261
+ }
262
+ if (askPaths) {
263
+ for (const one of declared(config, 'paths', PATH_SHAPE, [])) {
264
+ const only = pattern({ migrations: { ...migrations, paths: [one] } })
265
+ if (files.some((file) => only.test(file))) continue
266
+ warnings.push(`ops.config.json: migrations.paths "${one}" no alcanza a ningún archivo con las extensiones `
267
+ + `${declared(config, 'extensions', EXTENSION_SHAPE, DEFAULT_EXTENSIONS).join(', ')}`)
268
+ }
269
+ }
270
+ return warnings
271
+ }
272
+
273
+ module.exports = {
274
+ PATH_SHAPE, EXTENSION_SHAPE, pattern, judged, judgedPatch, patchSections, destructive, coverageWarnings,
275
+ }
@@ -34,7 +34,7 @@ function reposFor(opsRoot, service) {
34
34
  .filter(Boolean)
35
35
  // Dos raíces del mismo repositorio son un solo repositorio: lo ambiguo es a cuál pertenece el
36
36
  // servicio, no cuántas rutas lo contienen.
37
- .filter((repo, index, todos) => todos.indexOf(repo) === index)
37
+ .filter((repo, index, all) => all.indexOf(repo) === index)
38
38
  }
39
39
 
40
40
  // El repositorio del servicio cuando no hay duda. Sin ninguno o con varios devuelve vacío, y quien
@@ -84,7 +84,7 @@ function unrecordedCommits(repo, since, recorded) {
84
84
  // historial reescrito o un `commit --date` la cuenta daba cero sobre un repositorio lleno.
85
85
  const log = git(repo, 'log', '--no-merges', '--date=short', '--format=%h %ad %s')
86
86
  if (log.status !== 0) return []
87
- const conocidos = new Set([...recorded].map((sha) => String(sha).slice(0, 7)))
87
+ const known = new Set([...recorded].map((sha) => String(sha).slice(0, 7)))
88
88
  // El hash y la fecha se leen partiendo por espacios y no por columna: `%h` mide 7 por default y git lo
89
89
  // sube solo cuando el repositorio crece —en éste mide 8—, así que una posición fija leía un espacio en
90
90
  // vez de la fecha y ningún commit pasaba el filtro. El aviso quedaba mudo sin decirlo, que es la peor
@@ -92,7 +92,7 @@ function unrecordedCommits(repo, since, recorded) {
92
92
  return log.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
93
93
  .map((line) => ({ line, sha: line.split(/\s+/)[0], date: line.split(/\s+/)[1] }))
94
94
  .filter((one) => one.date >= since)
95
- .filter((one) => !conocidos.has(one.sha.slice(0, 7)))
95
+ .filter((one) => !known.has(one.sha.slice(0, 7)))
96
96
  .map((one) => one.line)
97
97
  }
98
98
 
@@ -153,4 +153,18 @@ function unrecordedHumanActions(opsRoot, rows) {
153
153
  .map((row) => `HUMAN_ACTIONS.md: ${row.task} figura resuelta y ningún commit la registró`)
154
154
  }
155
155
 
156
- module.exports = { reposFor, repoOf, lastCommit, coverageWarnings, unrecordedHumanActions }
156
+ // Qué archivos tocó un commit, buscado en todos los repositorios declarados: una entrada de DONE nombra
157
+ // el sha y no el repositorio. Devuelve null si ninguno lo conoce, y quien pregunta decide qué significa.
158
+ // `--root` es para que el primer commit de un repositorio también liste lo suyo.
159
+ function commitFiles(opsRoot) {
160
+ const repos = reposFor(opsRoot, '.')
161
+ return (sha) => {
162
+ for (const repo of repos) {
163
+ const shown = git(repo, 'diff-tree', '--no-commit-id', '--name-only', '-r', '--root', sha)
164
+ if (shown.status === 0) return shown.stdout.split('\n').map((line) => line.trim()).filter(Boolean)
165
+ }
166
+ return null
167
+ }
168
+ }
169
+
170
+ module.exports = { reposFor, repoOf, lastCommit, coverageWarnings, unrecordedHumanActions, commitFiles }