@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,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 }
|
package/engine/cli/args.js
CHANGED
|
@@ -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
|
}
|
package/engine/cli/catalog.js
CHANGED
|
@@ -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
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
const porque = result.
|
|
100
|
-
?
|
|
101
|
-
: '
|
|
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
|
|
114
|
-
// corridas y nunca lee `learning/reports/`. La forma desnuda —la que para un cargo abre
|
|
115
|
-
//
|
|
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
|
|
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)
|
|
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
|
|
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]
|
package/engine/cli/validate.js
CHANGED
|
@@ -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.
|
package/engine/cli/wiring.js
CHANGED
|
@@ -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 (
|
|
73
|
-
}
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
}
|
package/engine/core/repos.js
CHANGED
|
@@ -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,
|
|
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
|
|
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) => !
|
|
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
|
-
|
|
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 }
|