@saulwade/swl-ses 2.0.0 → 2.1.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/CLAUDE.md +196 -196
- package/README.md +579 -579
- package/agentes/_propose-step.md +90 -0
- package/agentes/implementador-swl.md +2 -0
- package/agentes/orquestador-swl.md +2 -0
- package/agentes/perfilador-usuario-swl.md +14 -1
- package/bin/swl-ses.js +1 -1
- package/comandos/swl/aprobar-plan.md +3 -2
- package/comandos/swl/briefing.md +122 -0
- package/comandos/swl/compactar.md +29 -2
- package/comandos/swl/discutir-fase.md +8 -5
- package/comandos/swl/ejecutar-fase.md +6 -0
- package/comandos/swl/planear-fase.md +5 -3
- package/comandos/swl/release.md +46 -0
- package/comandos/swl/status.md +69 -0
- package/comandos/swl/verificar.md +3 -2
- package/habilidades/changelog-generator/scripts/parse-commits.js +6 -4
- package/habilidades/ejecutar-fase/SKILL.md +541 -518
- package/habilidades/planear-fase/SKILL.md +3 -2
- package/habilidades/tdd-workflow/SKILL.md +715 -713
- package/habilidades/validacion-ci-sistema/SKILL.md +17 -1
- package/hooks/calidad-pre-commit.js +5 -1
- package/hooks/check-update.js +39 -1
- package/hooks/lib/autonomia.js +208 -0
- package/hooks/lib/briefing.js +474 -0
- package/hooks/lib/propose-step.js +357 -0
- package/hooks/session-briefing.js +98 -0
- package/hooks/telemetria-skill-routing.js +100 -0
- package/instintos/autonomia.yaml +27 -0
- package/llms.txt +4 -4
- package/manifiestos/hooks-config.json +18 -0
- package/manifiestos/modulos.json +25 -3
- package/manifiestos/skills-lock.json +14 -14
- package/package.json +93 -93
- package/plugin.json +371 -371
- package/reglas/analizar-directorios-antes-de-escribir.md +228 -0
- package/reglas/consultar-vault-primero.md +195 -0
- package/reglas/debatir-antes-de-aceptar.md +158 -0
- package/reglas/git-coauthor.md +100 -0
- package/reglas/monitor-ci.md +309 -0
- package/reglas/registro-componentes-nuevos.md +38 -10
- package/reglas/sesiones-paralelas.md +180 -0
- package/reglas/usar-code-review-graph.md +155 -0
- package/reglas/verificar-citas-normativas.md +548 -0
- package/scripts/instalador.js +52 -6
- package/scripts/lib/ci-reader.js +193 -0
- package/scripts/lib/detectar-host-swl.js +175 -0
- package/scripts/lib/evidencia-release.js +322 -0
- package/scripts/lib/gate-hooks-requires.js +249 -0
- package/scripts/lib/gate-licencias.js +212 -0
- package/scripts/lib/git-metricas.js +257 -0
- package/scripts/lib/metricas-dora.js +204 -0
- package/scripts/tui/ejecutores.js +1 -1
- package/scripts/validar-manifest.js +92 -1
- package/scripts/verificar-evolucion.js +54 -4
- package/scripts/verificar-release.js +102 -0
- package/scripts/verificar-trazabilidad.js +11 -5
- package/reglas/arquitectura.evolved.json +0 -7
- package/reglas/seguridad.evolved.json +0 -7
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ci-reader.js
|
|
5
|
+
*
|
|
6
|
+
* Métricas DORA dependientes de CI (GitHub Actions vía `gh run list`):
|
|
7
|
+
* - change failure rate: runs fallidos / runs completados en la ventana.
|
|
8
|
+
* - MTTR (mean/median time to restore): mediana del tiempo de un run fallido
|
|
9
|
+
* hasta el siguiente run exitoso.
|
|
10
|
+
*
|
|
11
|
+
* `gh` es OPCIONAL (D-15-01): si no está instalado, no autenticado, o el repo no
|
|
12
|
+
* tiene remoto GitHub, las funciones devuelven `{disponible:false, razon}` SIN
|
|
13
|
+
* lanzar. El executor `gh` es inyectable (`opts.ejecutorGh`) para tests sin gh
|
|
14
|
+
* real. Funciones puras sobre el array de runs. Entrypoint CLI `--json`.
|
|
15
|
+
* Parte de la Fase 15 (ADR-0039).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const { execFileSync } = require('node:child_process');
|
|
19
|
+
|
|
20
|
+
const MS_DIA = 24 * 60 * 60 * 1000;
|
|
21
|
+
const MS_HORA = 60 * 60 * 1000;
|
|
22
|
+
const LIMITE_DEFAULT = 200;
|
|
23
|
+
|
|
24
|
+
const CONCLUSIONES_FALLO = new Set(['failure', 'timed_out', 'startup_failure']);
|
|
25
|
+
const CONCLUSIONES_EXITO = new Set(['success']);
|
|
26
|
+
|
|
27
|
+
const GH_MAX_BUFFER = 8 * 1024 * 1024; // 8 MiB
|
|
28
|
+
const GH_TIMEOUT_MS = 8000; // gh puede tardar por red; degrada si excede (H-04)
|
|
29
|
+
|
|
30
|
+
/** Executor real de gh. Lanza si gh no está disponible/autenticado. */
|
|
31
|
+
function ejecutorGhReal(args) {
|
|
32
|
+
return execFileSync('gh', args, {
|
|
33
|
+
encoding: 'utf8',
|
|
34
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
35
|
+
maxBuffer: GH_MAX_BUFFER,
|
|
36
|
+
timeout: GH_TIMEOUT_MS,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Categoriza el fallo de gh sin filtrar e.message crudo a logs/persistencia (H-03). */
|
|
41
|
+
function _razonGh(e) {
|
|
42
|
+
if (e && e.code === 'ENOENT') return 'gh no instalado';
|
|
43
|
+
if (e && e.killed && e.signal) return 'gh excedió el timeout';
|
|
44
|
+
return 'gh no disponible o sin acceso al repo';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Obtiene los runs de CI. Degrada sin lanzar.
|
|
49
|
+
* @returns {{disponible:boolean, runs?:Array, razon?:string}}
|
|
50
|
+
*/
|
|
51
|
+
function obtenerRuns(opts = {}) {
|
|
52
|
+
const { ejecutorGh = ejecutorGhReal, limite = LIMITE_DEFAULT, rama } = opts;
|
|
53
|
+
let salida;
|
|
54
|
+
try {
|
|
55
|
+
salida = ejecutorGh([
|
|
56
|
+
'run', 'list',
|
|
57
|
+
'--json', 'databaseId,conclusion,createdAt,updatedAt,headBranch,workflowName',
|
|
58
|
+
'--limit', String(limite),
|
|
59
|
+
]);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
return { disponible: false, razon: _razonGh(e) };
|
|
62
|
+
}
|
|
63
|
+
let runs;
|
|
64
|
+
try {
|
|
65
|
+
runs = JSON.parse(salida);
|
|
66
|
+
} catch {
|
|
67
|
+
return { disponible: false, razon: 'salida de gh no es JSON válido' };
|
|
68
|
+
}
|
|
69
|
+
if (!Array.isArray(runs)) return { disponible: false, razon: 'salida de gh inesperada' };
|
|
70
|
+
if (rama) runs = runs.filter((r) => r && r.headBranch === rama);
|
|
71
|
+
return { disponible: true, runs };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function _runsEnVentana(runs, ventanaDias, hoy) {
|
|
75
|
+
const desde = new Date(hoy.getTime() - ventanaDias * MS_DIA).getTime();
|
|
76
|
+
const hasta = hoy.getTime();
|
|
77
|
+
return runs.filter((r) => {
|
|
78
|
+
const t = Date.parse(r && r.createdAt);
|
|
79
|
+
return Number.isFinite(t) && t >= desde && t <= hasta;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function _mediana(valoresOrdenados) {
|
|
84
|
+
const n = valoresOrdenados.length;
|
|
85
|
+
if (n === 0) return null;
|
|
86
|
+
const mid = Math.floor(n / 2);
|
|
87
|
+
return n % 2 ? valoresOrdenados[mid] : (valoresOrdenados[mid - 1] + valoresOrdenados[mid]) / 2;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Change failure rate en la ventana.
|
|
92
|
+
* @returns {{disponible:boolean, cfr?:number, totalRuns?:number, fallidos?:number,
|
|
93
|
+
* sinDatos?:boolean, razon?:string}}
|
|
94
|
+
*/
|
|
95
|
+
function changeFailureRate(opts = {}) {
|
|
96
|
+
const { ventanaDias = 30, hoy = new Date() } = opts;
|
|
97
|
+
const base = opts.runs ? { disponible: true, runs: opts.runs } : obtenerRuns(opts);
|
|
98
|
+
if (!base.disponible) return { disponible: false, razon: base.razon };
|
|
99
|
+
|
|
100
|
+
const enVentana = _runsEnVentana(base.runs, ventanaDias, hoy);
|
|
101
|
+
const completados = enVentana.filter(
|
|
102
|
+
(r) => CONCLUSIONES_EXITO.has(r.conclusion) || CONCLUSIONES_FALLO.has(r.conclusion)
|
|
103
|
+
);
|
|
104
|
+
if (completados.length === 0) {
|
|
105
|
+
return { disponible: true, sinDatos: true, totalRuns: 0, fallidos: 0, cfr: 0 };
|
|
106
|
+
}
|
|
107
|
+
const fallidos = completados.filter((r) => CONCLUSIONES_FALLO.has(r.conclusion)).length;
|
|
108
|
+
return {
|
|
109
|
+
disponible: true,
|
|
110
|
+
sinDatos: false,
|
|
111
|
+
totalRuns: completados.length,
|
|
112
|
+
fallidos,
|
|
113
|
+
cfr: fallidos / completados.length,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* MTTR: mediana del tiempo de un run fallido hasta el siguiente run exitoso.
|
|
119
|
+
* @returns {{disponible:boolean, mttrHoras?:number, recuperaciones?:number,
|
|
120
|
+
* sinDatos?:boolean, razon?:string}}
|
|
121
|
+
*/
|
|
122
|
+
function meanTimeToRestore(opts = {}) {
|
|
123
|
+
const { ventanaDias = 30, hoy = new Date() } = opts;
|
|
124
|
+
const base = opts.runs ? { disponible: true, runs: opts.runs } : obtenerRuns(opts);
|
|
125
|
+
if (!base.disponible) return { disponible: false, razon: base.razon };
|
|
126
|
+
|
|
127
|
+
const enVentana = _runsEnVentana(base.runs, ventanaDias, hoy)
|
|
128
|
+
.filter((r) => CONCLUSIONES_EXITO.has(r.conclusion) || CONCLUSIONES_FALLO.has(r.conclusion))
|
|
129
|
+
.map((r) => ({
|
|
130
|
+
fallo: CONCLUSIONES_FALLO.has(r.conclusion),
|
|
131
|
+
detectado: Date.parse(r.updatedAt || r.createdAt),
|
|
132
|
+
}))
|
|
133
|
+
.filter((r) => Number.isFinite(r.detectado))
|
|
134
|
+
.sort((a, b) => a.detectado - b.detectado);
|
|
135
|
+
|
|
136
|
+
const recuperaciones = [];
|
|
137
|
+
for (let i = 0; i < enVentana.length; i++) {
|
|
138
|
+
if (!enVentana[i].fallo) continue;
|
|
139
|
+
const exito = enVentana.slice(i + 1).find((r) => !r.fallo);
|
|
140
|
+
if (exito) {
|
|
141
|
+
const h = (exito.detectado - enVentana[i].detectado) / MS_HORA;
|
|
142
|
+
if (h >= 0) recuperaciones.push(h);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
recuperaciones.sort((a, b) => a - b);
|
|
146
|
+
if (recuperaciones.length === 0) {
|
|
147
|
+
return { disponible: true, sinDatos: true, recuperaciones: 0, mttrHoras: null };
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
disponible: true,
|
|
151
|
+
sinDatos: false,
|
|
152
|
+
recuperaciones: recuperaciones.length,
|
|
153
|
+
mttrHoras: _mediana(recuperaciones),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = {
|
|
158
|
+
obtenerRuns,
|
|
159
|
+
changeFailureRate,
|
|
160
|
+
meanTimeToRestore,
|
|
161
|
+
ejecutorGhReal,
|
|
162
|
+
CONCLUSIONES_FALLO,
|
|
163
|
+
CONCLUSIONES_EXITO,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// Entrypoint CLI: node scripts/lib/ci-reader.js [--json] [--dias=N]
|
|
167
|
+
if (!require.main || require.main === module) {
|
|
168
|
+
const args = process.argv.slice(2);
|
|
169
|
+
const json = args.includes('--json');
|
|
170
|
+
const diasArg = args.find((a) => a.startsWith('--dias='));
|
|
171
|
+
const ventanaDias = Math.max(1, Math.min(365, (diasArg ? parseInt(diasArg.slice('--dias='.length), 10) : 30) || 30));
|
|
172
|
+
|
|
173
|
+
// Una sola llamada a gh; si no está disponible, no se re-intenta.
|
|
174
|
+
const base = obtenerRuns({ limite: LIMITE_DEFAULT });
|
|
175
|
+
let out;
|
|
176
|
+
if (!base.disponible) {
|
|
177
|
+
out = { disponible: false, razon: base.razon };
|
|
178
|
+
} else {
|
|
179
|
+
const cfr = changeFailureRate({ ventanaDias, runs: base.runs });
|
|
180
|
+
const mttr = meanTimeToRestore({ ventanaDias, runs: base.runs });
|
|
181
|
+
out = { ventana_dias: ventanaDias, changeFailureRate: cfr, mttr };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (json) {
|
|
185
|
+
process.stdout.write(JSON.stringify(out, null, 2) + '\n');
|
|
186
|
+
} else if (!base.disponible) {
|
|
187
|
+
process.stdout.write(`CI no disponible: ${base.razon}\n`);
|
|
188
|
+
} else {
|
|
189
|
+
process.stdout.write(`Change failure rate: ${out.changeFailureRate.sinDatos ? 'sin datos' : (out.changeFailureRate.cfr * 100).toFixed(1) + '%'}\n`);
|
|
190
|
+
process.stdout.write(`MTTR: ${out.mttr.sinDatos ? 'sin datos' : out.mttr.mttrHoras.toFixed(1) + 'h'}\n`);
|
|
191
|
+
}
|
|
192
|
+
process.exit(0);
|
|
193
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* detectar-host-swl.js
|
|
5
|
+
*
|
|
6
|
+
* Determina si un directorio es el REPO HOST del sistema SWL (donde viven los
|
|
7
|
+
* componentes: agentes/, habilidades/, comandos/swl/, reglas/, hooks/) o un
|
|
8
|
+
* PROYECTO CONSUMIDOR que solo usa las convenciones SWL vía `~/.claude/`.
|
|
9
|
+
*
|
|
10
|
+
* Motivación: `/swl:status salud` audita los componentes del sistema SWL con un
|
|
11
|
+
* score ponderado (agentes×0.30 + skills×0.25 + comandos×0.20 + reglas×0.15 +
|
|
12
|
+
* hooks×0.10). En un proyecto consumidor esos directorios no existen → el score
|
|
13
|
+
* sería 0/100, señal falsa. Antes esta distinción dependía del juicio del agente
|
|
14
|
+
* de turno; este módulo la vuelve determinista (regla: salud usa verificaciones
|
|
15
|
+
* deterministas, no juicio subjetivo).
|
|
16
|
+
*
|
|
17
|
+
* Función pura zero-deps. Entrypoint CLI con `--json` para consumo desde el
|
|
18
|
+
* comando /swl:status salud.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
// Marca canónica del paquete en plugin.json (acepta forma con y sin scope npm).
|
|
25
|
+
const NOMBRES_HOST = new Set(['swl-ses', '@saulwade/swl-ses']);
|
|
26
|
+
|
|
27
|
+
// Subcomandos de /swl:status que SÍ aplican a un proyecto consumidor.
|
|
28
|
+
const SUGERENCIAS_CONSUMIDOR = [
|
|
29
|
+
'/swl:status metricas — tokens/costo de la sesión actual',
|
|
30
|
+
'/swl:status metricas fases — progreso de fases (si usa .planning/HOJA-RUTA.md)',
|
|
31
|
+
'/swl:status loops — telemetría de loops iterativos',
|
|
32
|
+
'/swl:status evolucion — ciclo de auto-evolución (si corre en este proyecto)',
|
|
33
|
+
'/swl:status dashboard — consumo multi-sesión',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Cuenta archivos de un componente sin recursión profunda innecesaria.
|
|
38
|
+
* @returns {number}
|
|
39
|
+
*/
|
|
40
|
+
function contarComponente(baseDir, rel, filtro) {
|
|
41
|
+
const dir = path.join(baseDir, rel);
|
|
42
|
+
let entradas;
|
|
43
|
+
try {
|
|
44
|
+
entradas = fs.readdirSync(dir, { withFileTypes: true });
|
|
45
|
+
} catch {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
return entradas.filter((e) => filtro(e)).length;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Cuenta los 5 componentes del sistema SWL en baseDir.
|
|
53
|
+
* @returns {{agentes:number, habilidades:number, comandos:number, reglas:number, hooks:number}}
|
|
54
|
+
*/
|
|
55
|
+
function contarComponentes(baseDir) {
|
|
56
|
+
return {
|
|
57
|
+
// Agentes: agentes/*.md excluyendo fragmentos compartidos (_*.md).
|
|
58
|
+
agentes: contarComponente(
|
|
59
|
+
baseDir,
|
|
60
|
+
'agentes',
|
|
61
|
+
(e) => e.isFile() && e.name.endsWith('.md') && !e.name.startsWith('_')
|
|
62
|
+
),
|
|
63
|
+
// Skills: habilidades/<nombre>/ (cada subdirectorio es un skill).
|
|
64
|
+
habilidades: contarComponente(baseDir, 'habilidades', (e) => e.isDirectory()),
|
|
65
|
+
// Comandos: comandos/swl/*.md.
|
|
66
|
+
comandos: contarComponente(
|
|
67
|
+
baseDir,
|
|
68
|
+
path.join('comandos', 'swl'),
|
|
69
|
+
(e) => e.isFile() && e.name.endsWith('.md')
|
|
70
|
+
),
|
|
71
|
+
// Reglas: reglas/*.md (base; las por-lenguaje viven en subdirectorios).
|
|
72
|
+
reglas: contarComponente(
|
|
73
|
+
baseDir,
|
|
74
|
+
'reglas',
|
|
75
|
+
(e) => e.isFile() && e.name.endsWith('.md')
|
|
76
|
+
),
|
|
77
|
+
// Hooks: hooks/*.js.
|
|
78
|
+
hooks: contarComponente(
|
|
79
|
+
baseDir,
|
|
80
|
+
'hooks',
|
|
81
|
+
(e) => e.isFile() && e.name.endsWith('.js')
|
|
82
|
+
),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Lee el nombre declarado en plugin.json si existe y es legible.
|
|
88
|
+
* @returns {string|null}
|
|
89
|
+
*/
|
|
90
|
+
function leerNombrePlugin(baseDir) {
|
|
91
|
+
try {
|
|
92
|
+
const raw = fs.readFileSync(path.join(baseDir, 'plugin.json'), 'utf-8');
|
|
93
|
+
const json = JSON.parse(raw);
|
|
94
|
+
return typeof json.name === 'string' ? json.name : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Detecta si baseDir es el repo host del sistema SWL.
|
|
102
|
+
*
|
|
103
|
+
* Criterio (cualquiera de los dos basta):
|
|
104
|
+
* 1. plugin.json con name ∈ {swl-ses, @saulwade/swl-ses}.
|
|
105
|
+
* 2. Presencia de los 3 directorios nucleares de componentes con contenido
|
|
106
|
+
* (agentes/, habilidades/, comandos/swl/) — cubre forks renombrados.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} [baseDir=process.cwd()]
|
|
109
|
+
* @returns {{esHost:boolean, razon:string, nombrePlugin:(string|null),
|
|
110
|
+
* componentes:object, sugerencias:string[]}}
|
|
111
|
+
*/
|
|
112
|
+
function detectarHostSwl(baseDir = process.cwd()) {
|
|
113
|
+
const nombrePlugin = leerNombrePlugin(baseDir);
|
|
114
|
+
const componentes = contarComponentes(baseDir);
|
|
115
|
+
|
|
116
|
+
const tienePluginHost = nombrePlugin !== null && NOMBRES_HOST.has(nombrePlugin);
|
|
117
|
+
const tieneNucleo =
|
|
118
|
+
componentes.agentes > 0 &&
|
|
119
|
+
componentes.habilidades > 0 &&
|
|
120
|
+
componentes.comandos > 0;
|
|
121
|
+
|
|
122
|
+
const esHost = tienePluginHost || tieneNucleo;
|
|
123
|
+
|
|
124
|
+
let razon;
|
|
125
|
+
if (tienePluginHost) {
|
|
126
|
+
razon = `plugin.json declara name="${nombrePlugin}" (repo host del sistema SWL).`;
|
|
127
|
+
} else if (tieneNucleo) {
|
|
128
|
+
razon =
|
|
129
|
+
'Presencia de componentes nucleares (agentes/, habilidades/, comandos/swl/) ' +
|
|
130
|
+
'sin plugin.json canónico — posible fork del sistema SWL.';
|
|
131
|
+
} else {
|
|
132
|
+
razon =
|
|
133
|
+
'Sin plugin.json del sistema y sin directorios de componentes ' +
|
|
134
|
+
'(agentes/, habilidades/, comandos/swl/): proyecto consumidor de SWL, ' +
|
|
135
|
+
'no host. El subcomando `salud` audita componentes que aquí no existen ' +
|
|
136
|
+
'por diseño — un score 0/100 sería engañoso.';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
esHost,
|
|
141
|
+
razon,
|
|
142
|
+
nombrePlugin,
|
|
143
|
+
componentes,
|
|
144
|
+
sugerencias: esHost ? [] : SUGERENCIAS_CONSUMIDOR.slice(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = {
|
|
149
|
+
detectarHostSwl,
|
|
150
|
+
contarComponentes,
|
|
151
|
+
NOMBRES_HOST,
|
|
152
|
+
SUGERENCIAS_CONSUMIDOR,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Entrypoint CLI: `node scripts/lib/detectar-host-swl.js [--json] [baseDir]`
|
|
156
|
+
if (!require.main || require.main === module) {
|
|
157
|
+
const args = process.argv.slice(2);
|
|
158
|
+
const json = args.includes('--json');
|
|
159
|
+
const dirArg = args.find((a) => !a.startsWith('--'));
|
|
160
|
+
const resultado = detectarHostSwl(dirArg || process.cwd());
|
|
161
|
+
|
|
162
|
+
if (json) {
|
|
163
|
+
process.stdout.write(JSON.stringify(resultado, null, 2) + '\n');
|
|
164
|
+
} else if (resultado.esHost) {
|
|
165
|
+
process.stdout.write(`[host] ${resultado.razon}\n`);
|
|
166
|
+
} else {
|
|
167
|
+
process.stdout.write(`[consumidor] ${resultado.razon}\n`);
|
|
168
|
+
process.stdout.write('Subcomandos de /swl:status aplicables aquí:\n');
|
|
169
|
+
for (const s of resultado.sugerencias) {
|
|
170
|
+
process.stdout.write(` - ${s}\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Exit 0 siempre: es diagnóstico, no gate bloqueante.
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* evidencia-release.js — Evidencia de procedencia del release (Fase 14, ADR-0038).
|
|
5
|
+
*
|
|
6
|
+
* Genera, como paso de `/swl:release`, los artefactos de cadena de suministro:
|
|
7
|
+
* 1. SBOM CycloneDX del árbol runtime (`npm sbom --sbom-format cyclonedx --omit dev`).
|
|
8
|
+
* 2. SHA256SUMS del tarball producido por `npm pack`.
|
|
9
|
+
* Ambos se escriben en `releases/vX.Y.Z/` (evidencia versionada en el repo, fuera
|
|
10
|
+
* del tarball npm — `package.json#files` es allowlist y no incluye `releases/`).
|
|
11
|
+
*
|
|
12
|
+
* Diseño: la parte PURA (nombres, formato, sección markdown, validación semver)
|
|
13
|
+
* se separa de la parte de SUBPROCESO (npm), inyectable vía `opts.ejecutarNpm`
|
|
14
|
+
* para tests sin tocar npm real.
|
|
15
|
+
*
|
|
16
|
+
* Subproceso npm cross-platform: se invoca `node <npm-cli.js> ...` (no `npm.cmd`)
|
|
17
|
+
* con execFileSync sin shell — patrón de scripts/lib/npm-version.js (evita la
|
|
18
|
+
* diferencia .cmd/POSIX y DEP0190 en Node 20+).
|
|
19
|
+
*
|
|
20
|
+
* Zero-dependencies. CLI: `node scripts/lib/evidencia-release.js --version X.Y.Z`.
|
|
21
|
+
*
|
|
22
|
+
* Origen: Fase 14 — cadena de suministro en release (P4, ADR-0038).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const os = require('os');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { execFileSync } = require('child_process');
|
|
30
|
+
|
|
31
|
+
// Escritura atómica con fallback defensivo idéntico a plan-lock.js (los archivos
|
|
32
|
+
// de evidencia son write-once por release, pero respetamos la convención del repo).
|
|
33
|
+
let atomicWriteSync;
|
|
34
|
+
try {
|
|
35
|
+
({ atomicWriteSync } = require(path.join(__dirname, '..', '..', 'hooks', 'lib', 'atomic-write.js')));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
// Solo degradar a writeFileSync si el módulo no existe (destino aplanado).
|
|
38
|
+
// Otros errores (permisos, disco) deben propagarse, no enmascararse.
|
|
39
|
+
if (err.code !== 'MODULE_NOT_FOUND') throw err;
|
|
40
|
+
atomicWriteSync = (p, c, e) => {
|
|
41
|
+
const dir = path.dirname(p);
|
|
42
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
43
|
+
fs.writeFileSync(p, c, e);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Semver estricto (core + prerelease + build), suficiente para validar la versión
|
|
48
|
+
// del release sin dependencias. No valida rangos — solo una versión concreta.
|
|
49
|
+
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
|
|
50
|
+
const SHA256_HEX_RE = /^[0-9a-f]{64}$/;
|
|
51
|
+
// Nombre de tarball seguro: lo que produce `npm pack` (scope-paquete-version.tgz).
|
|
52
|
+
// Rechaza newlines, espacios y control chars que corromperían SHA256SUMS.
|
|
53
|
+
const TARBALL_NOMBRE_RE = /^[A-Za-z0-9._@+-]+\.tgz$/;
|
|
54
|
+
// Cota de tamaño del SBOM antes de JSON.parse (defensa DoS por memoria).
|
|
55
|
+
const MAX_SBOM_BYTES = 32 * 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Localiza npm-cli.js de la instalación de Node para invocarlo vía `node`.
|
|
59
|
+
*
|
|
60
|
+
* Duplicación DELIBERADA del helper de scripts/lib/npm-version.js (~15 líneas).
|
|
61
|
+
* NO se importa de allí a propósito: `npm-version.js` NO se distribuye al destino
|
|
62
|
+
* (no está en ningún módulo de modulos.json), mientras esta lib SÍ viaja en
|
|
63
|
+
* `comandos-core`. Un `require('./npm-version')` rompería con MODULE_NOT_FOUND en
|
|
64
|
+
* el destino aplanado. La autocontención es el requisito de distribución, no una
|
|
65
|
+
* omisión — acoplar las dos libs introduciría fragilidad de runtime
|
|
66
|
+
* (arquitectura.md § "mantener código similar separado cuando la abstracción
|
|
67
|
+
* compartida sería vaga"). Si se agrega un candidato de ruta nuevo, replicarlo
|
|
68
|
+
* en ambas.
|
|
69
|
+
*
|
|
70
|
+
* @returns {string|null}
|
|
71
|
+
*/
|
|
72
|
+
function localizarNpmCli() {
|
|
73
|
+
const dir = path.dirname(process.execPath);
|
|
74
|
+
const candidatos = [
|
|
75
|
+
path.join(dir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
76
|
+
path.join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
77
|
+
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
|
78
|
+
];
|
|
79
|
+
for (const c of candidatos) {
|
|
80
|
+
try {
|
|
81
|
+
if (fs.existsSync(c)) return c;
|
|
82
|
+
} catch { /* continuar */ }
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Valida que `version` sea semver estricto; lanza si no.
|
|
89
|
+
* @param {string} version
|
|
90
|
+
*/
|
|
91
|
+
function assertSemver(version) {
|
|
92
|
+
if (typeof version !== 'string' || !SEMVER_RE.test(version)) {
|
|
93
|
+
throw new Error(`versión inválida (no es semver): ${JSON.stringify(version)}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Nombres de los artefactos de evidencia para una versión dada.
|
|
99
|
+
* @param {string} version semver, ej. "2.0.0"
|
|
100
|
+
* @returns {{dir: string, sbom: string, sums: string}}
|
|
101
|
+
*/
|
|
102
|
+
function nombreArtefactos(version) {
|
|
103
|
+
assertSemver(version);
|
|
104
|
+
return {
|
|
105
|
+
dir: `releases/v${version}`,
|
|
106
|
+
sbom: `sbom-v${version}.cdx.json`,
|
|
107
|
+
sums: 'SHA256SUMS',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Una línea de SHA256SUMS en formato `sha256sum -c` (hash + DOS espacios + nombre).
|
|
113
|
+
* @param {string} hash sha256 hex de 64 chars minúsculas
|
|
114
|
+
* @param {string} archivo nombre del archivo (tarball)
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function formatearSums(hash, archivo) {
|
|
118
|
+
if (typeof hash !== 'string' || !SHA256_HEX_RE.test(hash)) {
|
|
119
|
+
throw new Error(`hash inválido (se esperaba sha256 hex de 64 chars): ${JSON.stringify(hash)}`);
|
|
120
|
+
}
|
|
121
|
+
if (typeof archivo !== 'string' || !TARBALL_NOMBRE_RE.test(archivo)) {
|
|
122
|
+
// Evita que un nombre con newlines/espacios corrompa el formato sha256sum -c.
|
|
123
|
+
throw new Error(`nombre de tarball inválido: ${JSON.stringify(archivo)}`);
|
|
124
|
+
}
|
|
125
|
+
return `${hash} ${archivo}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sección markdown "Integridad y verificación" para insertar en RELEASE-NOTES.
|
|
130
|
+
* @param {{version: string, hash: string, tarball: string}} datos
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
function seccionVerificacionNotas({ version, hash, tarball }) {
|
|
134
|
+
assertSemver(version);
|
|
135
|
+
if (!SHA256_HEX_RE.test(hash)) {
|
|
136
|
+
throw new Error(`hash inválido en seccionVerificacionNotas: ${JSON.stringify(hash)}`);
|
|
137
|
+
}
|
|
138
|
+
const n = nombreArtefactos(version);
|
|
139
|
+
return [
|
|
140
|
+
'## Integridad y verificación',
|
|
141
|
+
'',
|
|
142
|
+
'Evidencia de procedencia versionada en `' + n.dir + '/`:',
|
|
143
|
+
'',
|
|
144
|
+
'- **SBOM** (CycloneDX): `' + n.sbom + '`',
|
|
145
|
+
'- **Checksums**: `SHA256SUMS`',
|
|
146
|
+
'',
|
|
147
|
+
'SHA256 del tarball publicado:',
|
|
148
|
+
'',
|
|
149
|
+
'```',
|
|
150
|
+
formatearSums(hash, tarball),
|
|
151
|
+
'```',
|
|
152
|
+
'',
|
|
153
|
+
'Verificar la integridad tras descargar el paquete:',
|
|
154
|
+
'',
|
|
155
|
+
'```bash',
|
|
156
|
+
`# Bash`,
|
|
157
|
+
`npm pack @saulwade/swl-ses@${version}`,
|
|
158
|
+
`sha256sum -c ${n.dir}/SHA256SUMS`,
|
|
159
|
+
'```',
|
|
160
|
+
'',
|
|
161
|
+
'```powershell',
|
|
162
|
+
`# PowerShell`,
|
|
163
|
+
`(Get-FileHash ${tarball} -Algorithm SHA256).Hash.ToLower()`,
|
|
164
|
+
`# debe coincidir con el hash de arriba`,
|
|
165
|
+
'```',
|
|
166
|
+
'',
|
|
167
|
+
'Detalle completo en `docs/verificacion-consumidor.md`.',
|
|
168
|
+
'',
|
|
169
|
+
].join('\n');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Ejecutor npm real (default). Inyectable en tests vía opts.ejecutarNpm.
|
|
174
|
+
* @returns {{sbom: () => string, pack: (destino: string) => string}}
|
|
175
|
+
*/
|
|
176
|
+
function ejecutorNpmReal() {
|
|
177
|
+
const npmCli = localizarNpmCli();
|
|
178
|
+
if (!npmCli) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
'no se encontró npm-cli.js de la instalación de Node; ' +
|
|
181
|
+
'evidencia-release requiere npm para generar SBOM y empacar el tarball'
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
const correr = (args, opts = {}) =>
|
|
185
|
+
execFileSync(process.execPath, [npmCli, ...args], {
|
|
186
|
+
encoding: 'utf8',
|
|
187
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
188
|
+
...opts,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
sbom() {
|
|
193
|
+
return correr(['sbom', '--sbom-format', 'cyclonedx', '--omit', 'dev']);
|
|
194
|
+
},
|
|
195
|
+
pack(destino) {
|
|
196
|
+
fs.mkdirSync(destino, { recursive: true });
|
|
197
|
+
// NO usar `npm pack --json`: los scripts de ciclo de vida (prepack)
|
|
198
|
+
// escriben a stdout y contaminan el JSON. En su lugar empacamos a un
|
|
199
|
+
// directorio limpio y leemos el único .tgz resultante — robusto ante
|
|
200
|
+
// cualquier salida de prepack/postpack.
|
|
201
|
+
correr(['pack', '--pack-destination', destino]);
|
|
202
|
+
const tgz = fs.readdirSync(destino).filter((f) => f.endsWith('.tgz'));
|
|
203
|
+
if (tgz.length === 0) {
|
|
204
|
+
throw new Error(`npm pack no produjo ningún .tgz en ${destino}`);
|
|
205
|
+
}
|
|
206
|
+
if (tgz.length > 1) {
|
|
207
|
+
// El destino es temporal y exclusivo por invocación (pid+timestamp);
|
|
208
|
+
// más de un tarball indica un destino reutilizado — usar el más reciente.
|
|
209
|
+
tgz.sort((a, b) =>
|
|
210
|
+
fs.statSync(path.join(destino, b)).mtimeMs - fs.statSync(path.join(destino, a)).mtimeMs
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return path.join(destino, tgz[0]);
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Valida y persiste el SBOM. Rechaza salidas no-CycloneDX o demasiado grandes.
|
|
220
|
+
* @param {string} dir directorio releases/vX.Y.Z
|
|
221
|
+
* @param {string} sbomTexto salida cruda de `npm sbom`
|
|
222
|
+
* @param {string} nombre nombre del archivo SBOM
|
|
223
|
+
* @returns {string} ruta del SBOM escrito
|
|
224
|
+
*/
|
|
225
|
+
function persistirSbom(dir, sbomTexto, nombre) {
|
|
226
|
+
if (typeof sbomTexto !== 'string' || sbomTexto.length > MAX_SBOM_BYTES) {
|
|
227
|
+
throw new Error(`SBOM ausente o excede ${MAX_SBOM_BYTES} bytes`);
|
|
228
|
+
}
|
|
229
|
+
const sbomObj = JSON.parse(sbomTexto);
|
|
230
|
+
if (!sbomObj || sbomObj.bomFormat !== 'CycloneDX') {
|
|
231
|
+
throw new Error(`SBOM no es CycloneDX (bomFormat: ${sbomObj && sbomObj.bomFormat})`);
|
|
232
|
+
}
|
|
233
|
+
const sbomPath = path.join(dir, nombre);
|
|
234
|
+
atomicWriteSync(sbomPath, `${JSON.stringify(sbomObj, null, 2)}\n`, 'utf8');
|
|
235
|
+
return sbomPath;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Empaca el tarball en un dir temporal, calcula su sha256 y limpia el temporal.
|
|
240
|
+
* @param {object} npm ejecutor con pack(destino)
|
|
241
|
+
* @returns {{hash: string, tarball: string}}
|
|
242
|
+
*/
|
|
243
|
+
function calcularChecksumTarball(npm) {
|
|
244
|
+
const packTmp = path.join(os.tmpdir(), `swl-pack-${process.pid}-${Date.now()}`);
|
|
245
|
+
try {
|
|
246
|
+
const tarballPath = npm.pack(packTmp);
|
|
247
|
+
const buffer = fs.readFileSync(tarballPath);
|
|
248
|
+
const hash = crypto.createHash('sha256').update(buffer).digest('hex');
|
|
249
|
+
return { hash, tarball: path.basename(tarballPath) };
|
|
250
|
+
} finally {
|
|
251
|
+
try {
|
|
252
|
+
fs.rmSync(packTmp, { recursive: true, force: true });
|
|
253
|
+
} catch { /* best-effort */ }
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Genera la evidencia de procedencia para `version` bajo `baseDir`.
|
|
259
|
+
* Escribe releases/vX.Y.Z/{sbom-vX.Y.Z.cdx.json, SHA256SUMS}.
|
|
260
|
+
*
|
|
261
|
+
* @param {string} baseDir raíz del proyecto donde se escribe releases/
|
|
262
|
+
* @param {string} version semver del release
|
|
263
|
+
* @param {object} [opts]
|
|
264
|
+
* @param {object} [opts.ejecutarNpm] ejecutor {sbom(), pack(destino)} inyectable
|
|
265
|
+
* @returns {{hash: string, tarball: string, dir: string, sbomPath: string, sumsPath: string}}
|
|
266
|
+
*/
|
|
267
|
+
function generarEvidencia(baseDir, version, opts = {}) {
|
|
268
|
+
assertSemver(version); // valida ANTES de invocar npm
|
|
269
|
+
const npm = opts.ejecutarNpm || ejecutorNpmReal();
|
|
270
|
+
const n = nombreArtefactos(version);
|
|
271
|
+
const dir = path.join(baseDir, n.dir);
|
|
272
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
273
|
+
|
|
274
|
+
const sbomPath = persistirSbom(dir, npm.sbom(), n.sbom);
|
|
275
|
+
const { hash, tarball } = calcularChecksumTarball(npm);
|
|
276
|
+
const sumsPath = path.join(dir, n.sums);
|
|
277
|
+
atomicWriteSync(sumsPath, `${formatearSums(hash, tarball)}\n`, 'utf8');
|
|
278
|
+
|
|
279
|
+
return { hash, tarball, dir, sbomPath, sumsPath };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// --- CLI ---------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
function parseArgs(argv) {
|
|
285
|
+
const out = { version: null };
|
|
286
|
+
for (let i = 0; i < argv.length; i++) {
|
|
287
|
+
if (argv[i] === '--version') {
|
|
288
|
+
out.version = argv[i + 1];
|
|
289
|
+
i++;
|
|
290
|
+
} else if (argv[i].startsWith('--version=')) {
|
|
291
|
+
out.version = argv[i].slice('--version='.length);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return out;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (require.main === module) {
|
|
298
|
+
const { version } = parseArgs(process.argv.slice(2));
|
|
299
|
+
if (!version) {
|
|
300
|
+
console.error('uso: node scripts/lib/evidencia-release.js --version X.Y.Z');
|
|
301
|
+
process.exit(2);
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const res = generarEvidencia(process.cwd(), version);
|
|
305
|
+
console.log(`Evidencia generada en ${path.relative(process.cwd(), res.dir)}/`);
|
|
306
|
+
console.log(` SBOM: ${path.basename(res.sbomPath)}`);
|
|
307
|
+
console.log(` SHA256SUMS: ${res.hash} ${res.tarball}`);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
console.error(`error generando evidencia: ${err.message}`);
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
module.exports = {
|
|
315
|
+
nombreArtefactos,
|
|
316
|
+
formatearSums,
|
|
317
|
+
seccionVerificacionNotas,
|
|
318
|
+
generarEvidencia,
|
|
319
|
+
ejecutorNpmReal,
|
|
320
|
+
localizarNpmCli,
|
|
321
|
+
SEMVER_RE,
|
|
322
|
+
};
|