@ingeniomaps/cauce 0.98.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.
@@ -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 }
@@ -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 }
@@ -79,6 +79,17 @@ function where(input) {
79
79
  return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file
80
80
  }
81
81
 
82
+ // Lo que el último mensaje de la persona negaba no quedó esperando su confirmación, así que un «dale» no
83
+ // lo aprobaría: ofrecerlo sería mandarla a contestar para volver a frenar (caso 188). Lo que sí lo pasa es
84
+ // pedirlo nombrándolo, que es una orden y no una confirmación.
85
+ function REFUSED(items, input = {}) {
86
+ return `Lo último que dijo la persona niega o frena ${items.join(', ')}, así que no quedó esperando su `
87
+ + 'confirmación y un sí no lo aprobaría: no reintentes. '
88
+ + (input.agent_id
89
+ ? 'Devolvele el bloqueo a quien te lanzó. '
90
+ : 'Si lo quiere, que lo pida en el chat nombrándolo. ')
91
+ }
92
+
82
93
  // Cómo se toma la salida angosta, dicho una vez porque lo dicen todos los bloqueos que la tienen. Lleva
83
94
  // las líneas exactas porque cada guard coteja la ruta en la forma que tiene a mano —absoluta la que llega
84
95
  // de un Write, relativa al repositorio la que sale del índice— y una línea en la otra forma no pega: sin
@@ -94,7 +105,9 @@ function where(input) {
94
105
  // que tiene a mano no sirve para pegar —por qué, en `secrets-shell.js` (caso 118)—. Lo frenado se anota
95
106
  // igual, así que el «dale» sigue cubriendo todo.
96
107
  function HOW(variable, lines, input, pasteable = lines) {
97
- const chat = CHAT.hold(input, lines)
108
+ const held = CHAT.hold(input, lines)
109
+ const dropped = held ? held.dropped : []
110
+ const chat = held && dropped.length < lines.length
98
111
  const stuck = lines.filter((one) => !pasteable.includes(one))
99
112
  // El alcance del «dale» va con la oferta y no después. La rama del pegado ya decía el suyo —«valen para
100
113
  // ese conjunto»— y la del chat no decía ninguno, siendo la que se ofrece primero. Las dos mitades que
@@ -103,18 +116,34 @@ function HOW(variable, lines, input, pasteable = lines) {
103
116
  // siguientes** es la dirección permisiva, la que nadie nota porque lo que no ocurre es un bloqueo.
104
117
  // `check` la muestra al final de la corrida —es lo que trajo el 117—, y al concederla no la decía nadie
105
118
  // (caso 170).
106
- const ask = chat
107
- ? 'Decile a la persona qué se frenó y por qué, y pedile que lo confirme con sus palabras: un «dale» '
119
+ // Un subagente no puede esperar la respuesta —su turno termina antes—, así que lo que le toca es
120
+ // devolver el bloqueo: preguntar lo hace quien lo lanzó, y el reintento pasa aunque vuelva a ser delegado
121
+ // (caso 186).
122
+ const lead = input.agent_id
123
+ ? 'Sos un subagente y no podés esperar la respuesta: devolvele a quien te lanzó qué se frenó y por qué, '
124
+ + 'para que se lo pregunte a la persona. Si ella lo confirma, el mismo cambio pasa aunque lo reintente '
125
+ + 'un subagente. '
126
+ : 'Decile a la persona qué se frenó y por qué, y pedile que lo confirme con sus palabras: un «dale» '
108
127
  + 'alcanza, pero no hace falta esa palabra. Si lo que contesta es un sí, reintentá el mismo cambio y '
109
128
  + 'pasa; si duda, pregunta o dice que no, no reintentes. Juzgarlo te toca a vos: el guard sólo frena la '
110
- + 'respuesta que niega, frena o pregunta. Esa confirmación cubre lo que se frenó y nada más —algo nuevo '
111
- + 'vuelve a frenar— y sigue valiendo en los mensajes siguientes hasta que ella lo niegue. '
129
+ + 'respuesta que niega, frena o pregunta. '
130
+ const ask = chat
131
+ ? lead + 'Esa confirmación cubre lo que se frenó y nada más —algo nuevo vuelve a frenar— y sigue valiendo '
132
+ + 'en los mensajes siguientes hasta que ella lo niegue. '
112
133
  : ''
134
+ const refused = dropped.length ? REFUSED(dropped, input) : ''
135
+ // Sin chat la salida es la misma, y también se dice como cosa de ella: el imperativo que el párrafo de
136
+ // arriba sacó de la rama con chat seguía acá, y es lo único que ve quien no tiene a nadie en el chat
137
+ // (caso 194). A quien corre el guard le toca otra cosa, y se le dice cuál. Vale también cuando hay persona
138
+ // pero no se le ofrece contestar, porque su último mensaje negaba todo lo frenado.
113
139
  const paste = pasteable.length
114
- ? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella tal cual en' : 'Aprobalo pegando tal cual en')
140
+ ? (chat ? 'Si prefiere aprobarlo a mano, que pegue ella' : 'Esto lo aprueba una persona: que pegue ella')
141
+ + ' tal cual en'
115
142
  + ` ${where(input)} estas líneas:\n`
116
143
  + pasteable.map((line) => ` ${line}\n`).join('')
117
144
  + 'Valen para ese conjunto y dejan de valer en cuanto cambie. '
145
+ + (chat ? '' : 'Vos no lo escribas —un guard lo frena—: decí qué se frenó y dónde, y reintentá cuando '
146
+ + 'esté; si sos un subagente, devolvele el bloqueo a quien te lanzó. ')
118
147
  : ''
119
148
  const unresolved = stuck.length
120
149
  ? `Por archivo no hay línea que pegar para ${stuck.join(', ')}: la ruta llegó con una expansión del shell `
@@ -128,7 +157,7 @@ function HOW(variable, lines, input, pasteable = lines) {
128
157
  ? `La variable ${variable}=1 sigue existiendo y apaga el guard para toda la sesión, que es por lo que no `
129
158
  + 'es la vía recomendada.'
130
159
  : ''
131
- return ask + paste + unresolved + off
160
+ return ask + refused + paste + unresolved + off
132
161
  }
133
162
 
134
163
  // Las dos exenciones que sobreviven a un bloqueo, para que `check` las muestre juntas: la lista que una
@@ -153,4 +182,4 @@ function warnings(root) {
153
182
  return out
154
183
  }
155
184
 
156
- module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW, warnings }
185
+ module.exports = { APPROVAL, lines, read, pending, pendingNow, where, HOW, REFUSED, warnings }