pumuki 6.3.37 → 6.3.38
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/VERSION +1 -1
- package/docs/README.md +1 -1
- package/docs/RELEASE_NOTES.md +27 -0
- package/docs/registro-maestro-de-seguimiento.md +7 -6
- package/docs/seguimiento-activo-pumuki-saas-supermercados.md +77 -0
- package/docs/seguimiento-completo-validacion-ruralgo-03-03-2026.md +27 -3
- package/integrations/gate/evaluateAiGate.ts +20 -2
- package/integrations/git/GitService.ts +28 -1
- package/integrations/git/gitAtomicity.ts +274 -0
- package/integrations/git/runPlatformGate.ts +7 -0
- package/integrations/git/stageRunners.ts +193 -4
- package/integrations/lifecycle/adapter.templates.json +10 -10
- package/integrations/lifecycle/cli.ts +44 -1
- package/integrations/lifecycle/doctor.ts +8 -3
- package/integrations/lifecycle/hookBlock.ts +37 -11
- package/integrations/lifecycle/packageInfo.ts +27 -1
- package/integrations/lifecycle/status.ts +1 -1
- package/integrations/mcp/autoExecuteAiStart.ts +116 -0
- package/integrations/mcp/enterpriseServer.ts +56 -0
- package/integrations/mcp/preFlightCheck.ts +108 -0
- package/integrations/notifications/emitAuditSummaryNotification.ts +28 -0
- package/package.json +1 -1
- package/scripts/framework-menu-consumer-preflight-lib.ts +11 -0
- package/scripts/framework-menu-evidence-summary-lib.ts +1 -1
- package/scripts/framework-menu-system-notifications-lib.ts +281 -17
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
v6.3.
|
|
1
|
+
v6.3.38
|
package/docs/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Canonical index for active Pumuki documentation.
|
|
|
5
5
|
## Seguimiento Activo (único)
|
|
6
6
|
|
|
7
7
|
- Maestro: `docs/registro-maestro-de-seguimiento.md`
|
|
8
|
-
- Plan activo: `docs/seguimiento-
|
|
8
|
+
- Plan activo: `docs/seguimiento-activo-pumuki-saas-supermercados.md`
|
|
9
9
|
- Política: una sola tarea en construcción (`🚧`) en el plan activo.
|
|
10
10
|
|
|
11
11
|
## Product and Architecture
|
package/docs/RELEASE_NOTES.md
CHANGED
|
@@ -5,6 +5,33 @@ Detailed commit history remains available through Git history (`git log` / `git
|
|
|
5
5
|
|
|
6
6
|
## 2026-03 (enterprise hardening updates)
|
|
7
7
|
|
|
8
|
+
### 2026-03-04 (v6.3.38)
|
|
9
|
+
|
|
10
|
+
- Blocked notification UX hardening for macOS:
|
|
11
|
+
- short, human-readable Spanish banner (`🔴 Pumuki bloqueado`) with stage + summarized cause.
|
|
12
|
+
- remediation-first body (`Solución: ...`) to maximize visibility in truncated notification banners.
|
|
13
|
+
- Optional blocked-dialog workflow (`PUMUKI_MACOS_BLOCKED_DIALOG=1`):
|
|
14
|
+
- full cause/remediation modal for critical blocks.
|
|
15
|
+
- anti-spam controls in dialog:
|
|
16
|
+
- `Mantener activas`
|
|
17
|
+
- `Silenciar 30 min`
|
|
18
|
+
- `Desactivar`
|
|
19
|
+
- `giving up after 15` timeout to avoid local execution hangs.
|
|
20
|
+
- Notification delivery contract update:
|
|
21
|
+
- config supports `muteUntil` in `.pumuki/system-notifications.json`.
|
|
22
|
+
- delivery result now reports `reason=muted` while silence window is active.
|
|
23
|
+
- Regression baseline alignment:
|
|
24
|
+
- `integrations/git/__tests__/stageRunners.test.ts` updated to run with core skills enabled in test harness, matching current gate contract and eliminating false failures.
|
|
25
|
+
- Traceability:
|
|
26
|
+
- commits: `2f957a2`, `ceb1849`, `98fc108`, `ae90f31`
|
|
27
|
+
- Consumer quick verification:
|
|
28
|
+
- `npx --yes tsx@4.21.0 --test scripts/__tests__/framework-menu-system-notifications.test.ts`
|
|
29
|
+
- `npx --yes tsx@4.21.0 --test integrations/git/__tests__/stageRunners.test.ts`
|
|
30
|
+
- `PUMUKI_MACOS_BLOCKED_DIALOG=1 npx --yes tsx@4.21.0 -e "import { emitSystemNotification } from './scripts/framework-menu-system-notifications-lib'; console.log(JSON.stringify(emitSystemNotification({ repoRoot: process.cwd(), event: { kind: 'gate.blocked', stage: 'PRE_COMMIT', totalViolations: 1, causeCode: 'BACKEND_AVOID_EXPLICIT_ANY', causeMessage: 'Avoid explicit any in backend code.', remediation: 'Tipa el valor y elimina any explícito en backend.' } })));"`
|
|
31
|
+
- expected signal:
|
|
32
|
+
- banner appears with concise remediation text,
|
|
33
|
+
- optional dialog appears with anti-spam actions when flag is enabled.
|
|
34
|
+
|
|
8
35
|
### 2026-03-04 (v6.3.37)
|
|
9
36
|
|
|
10
37
|
- Policy-as-code enterprise hardening shipped:
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
- Referenciar un único plan activo con fases, tasks y leyenda.
|
|
6
6
|
|
|
7
7
|
## Estado actual
|
|
8
|
-
- Plan activo: `docs/seguimiento-
|
|
9
|
-
- Estado del plan:
|
|
10
|
-
- Última task cerrada (`✅`):
|
|
11
|
-
- Task activa (`🚧`):
|
|
8
|
+
- Plan activo: `docs/seguimiento-activo-pumuki-saas-supermercados.md`
|
|
9
|
+
- Estado del plan: EJECUCION
|
|
10
|
+
- Última task cerrada (`✅`): Fase 3.2 (actualización de `CHANGELOG.md` + `docs/RELEASE_NOTES.md` con fixes reales).
|
|
11
|
+
- Task activa (`🚧`): Fase 3.3 publicación de versión.
|
|
12
|
+
- Nuevos pendientes añadidos (`⏳`): sin cambios.
|
|
12
13
|
|
|
13
14
|
## Historial resumido
|
|
14
|
-
-
|
|
15
|
-
-
|
|
15
|
+
- Bloque RuralGO cerrado: `docs/seguimiento-completo-validacion-ruralgo-03-03-2026.md`.
|
|
16
|
+
- Se inicia bloque SAAS_SUPERMERCADOS con plan activo único y legible.
|
|
16
17
|
|
|
17
18
|
## Regla de operación
|
|
18
19
|
- Debe existir exactamente una task `🚧` en el plan activo.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Plan Activo Pumuki SAAS Supermercados
|
|
2
|
+
|
|
3
|
+
## Leyenda
|
|
4
|
+
|
|
5
|
+
- ✅ Cerrado
|
|
6
|
+
- 🚧 En construccion (maximo 1)
|
|
7
|
+
- ⏳ Pendiente
|
|
8
|
+
- ⛔ Bloqueado
|
|
9
|
+
|
|
10
|
+
## Objetivo
|
|
11
|
+
|
|
12
|
+
- Resolver e implementar bugs y mejoras reportados en:
|
|
13
|
+
- `/Users/juancarlosmerlosalbarracin/Developer/Projects/SAAS:APP_SUPERMERCADOS/docs/pumuki/PUMUKI_BUGS_MEJORAS.md`
|
|
14
|
+
- Mantener trazabilidad: hallazgo -> fix -> test -> release notes.
|
|
15
|
+
|
|
16
|
+
## Fase 0. Intake y priorizacion
|
|
17
|
+
|
|
18
|
+
- ✅ Consolidar hallazgos y deduplicar causas raiz del MD canónico.
|
|
19
|
+
- ✅ Priorizar por impacto inicial:
|
|
20
|
+
- P1: `PUMUKI-001`, `PUMUKI-003`, `PUMUKI-005`, `PUMUKI-007`
|
|
21
|
+
- P2: `PUMUKI-002`, `PUMUKI-004`, `PUMUKI-006`
|
|
22
|
+
|
|
23
|
+
## Fase 1. Bugs P1 (ejecucion tecnica)
|
|
24
|
+
|
|
25
|
+
- ✅ PUMUKI-001: Compatibilidad de receipt MCP entre stages (`PRE_WRITE` vs `PRE_COMMIT`) sin bloqueo falso.
|
|
26
|
+
- ✅ PUMUKI-003: Endurecer resolución de binarios en hooks/scripts para evitar `command not found`.
|
|
27
|
+
- ✅ PUMUKI-005: Soporte robusto para repos con `:` en path (evitar dependencia frágil de PATH).
|
|
28
|
+
- ✅ PUMUKI-007: Soportar repos sin commits (`HEAD` ausente) sin error ambiguo.
|
|
29
|
+
|
|
30
|
+
## Fase 2. Mejoras P2
|
|
31
|
+
|
|
32
|
+
- ✅ PUMUKI-002: Rule-pack opcional de atomicidad Git + trazabilidad de commit message.
|
|
33
|
+
- ✅ PUMUKI-004: Mejorar diagnóstico de hooks efectivos en escenarios versionados/custom.
|
|
34
|
+
- ✅ PUMUKI-006: Alinear `package_version` reportada por MCP con versión local efectiva del repo consumidor.
|
|
35
|
+
|
|
36
|
+
## Fase 2.1 Paridad legacy (CLI vs MCP) en SAAS_SUPERMERCADOS
|
|
37
|
+
|
|
38
|
+
- ✅ PUMUKI-008: Feedback iterativo en chat no equivalente a flujo legacy.
|
|
39
|
+
- Evidencia: en ejecución MCP no aparece feedback operativo por iteración del modelo como en el grafo legacy.
|
|
40
|
+
- Esperado: resumen corto y humano en cada iteración (`stage`, `decision`, `next_action`).
|
|
41
|
+
- Entregable: tool MCP de pre-flight para chat con salida estable y accionable.
|
|
42
|
+
- ✅ PUMUKI-009: Desalineación operativa entre `ai_gate_check` y `pre_flight_check`.
|
|
43
|
+
- Evidencia: `ai_gate_check => BLOCKED (EVIDENCE_STALE)` mientras `pre_flight_check => allowed=true`.
|
|
44
|
+
- Esperado: criterio homogéneo o explicación explícita y trazable de por qué uno bloquea y el otro permite.
|
|
45
|
+
- Entregable: decisión unificada desde el mismo evaluador/política.
|
|
46
|
+
- ✅ PUMUKI-010: Respuesta no accionable en `auto_execute_ai_start` para confianza media.
|
|
47
|
+
- Evidencia: `success=true`, `action=ask`, `message=Medium confidence (undefined%)...`.
|
|
48
|
+
- Esperado: `next_action` determinista + confidence numérico consistente + remediación concreta.
|
|
49
|
+
- Entregable: contrato MCP estable (`confidence_pct`, `reason_code`, `next_action`).
|
|
50
|
+
- ✅ PUMUKI-011: Notificación macOS obligatoria en cualquier bloqueo de gate/fase.
|
|
51
|
+
- Requisito hard: cuando Pumuki bloquee (`PRE_WRITE`, `PRE_COMMIT`, `PRE_PUSH`, `CI`), lanzar notificación nativa macOS con sonido.
|
|
52
|
+
- Contenido mínimo: `🔴 BLOQUEADO`, causa exacta (`code + message`) y `cómo solucionarlo` (`next_action`).
|
|
53
|
+
- Entregable: comportamiento consistente en CLI, hooks y herramientas MCP con formato humano.
|
|
54
|
+
- Ajuste UX (2026-03-04): mensaje corto y legible para humanos.
|
|
55
|
+
- Nuevo formato: subtítulo con causa resumida + cuerpo iniciando por `Solución: ...` para que no se corte la remediación.
|
|
56
|
+
- ✅ PoC (2026-03-04): modo opcional de diálogo completo para bloqueo en macOS con `PUMUKI_MACOS_BLOCKED_DIALOG=1` (banner corto + modal con causa/solución completas).
|
|
57
|
+
- ✅ PoC anti-spam (2026-03-04): diálogo con acciones de control (`Mantener activas`, `Silenciar 30 min`, `Desactivar`) + timeout automático de 15s para no bloquear flujo.
|
|
58
|
+
|
|
59
|
+
## Decisión de producto (hard)
|
|
60
|
+
|
|
61
|
+
- La automatización es obligatoria: minimizar pasos manuales en bootstrap, pre-flight, gate y remediación.
|
|
62
|
+
- Objetivo operativo: que instalación + wiring de agente dejen el flujo listo para ejecutar y reportar bloqueos sin intervención manual extra.
|
|
63
|
+
|
|
64
|
+
## Aclaración operativa (para no perderse)
|
|
65
|
+
|
|
66
|
+
- CLI: lo ejecutan hooks Git (`pre-commit`, `pre-push`) y comandos manuales (`pumuki ...`).
|
|
67
|
+
- MCP: lo consume el agente/herramienta (Codex/Cursor/Windsurf...) cuando está configurado.
|
|
68
|
+
- `pumuki install`: instala hooks Git y bootstrap base.
|
|
69
|
+
- `pumuki adapter install --agent=<...>`: cablea hooks de agente + servidores MCP en el entorno del agente.
|
|
70
|
+
|
|
71
|
+
## Fase 3. Cierre
|
|
72
|
+
|
|
73
|
+
- ✅ Ejecutar suite de tests de regresión afectada.
|
|
74
|
+
- Evidencia (2026-03-04): `npx --yes tsx@4.21.0 --test scripts/__tests__/framework-menu-system-notifications.test.ts integrations/git/__tests__/stageRunners.test.ts integrations/lifecycle/__tests__/lifecycle.test.ts` -> `44 pass / 0 fail`.
|
|
75
|
+
- ✅ Actualizar `CHANGELOG.md` y `docs/RELEASE_NOTES.md` con fixes reales.
|
|
76
|
+
- Evidencia (2026-03-04): se documenta en `Unreleased` (CHANGELOG) y en `next patch candidate` (RELEASE_NOTES) el paquete de mejoras `PUMUKI-011` + baseline test alignment.
|
|
77
|
+
- 🚧 Publicar versión cuando las tareas en construcción/pending críticas estén cerradas.
|
|
@@ -1994,10 +1994,34 @@ Criterio de salida F5:
|
|
|
1994
1994
|
- `npx --yes tsx@4.21.0 --test integrations/gate/__tests__/stagePolicies.test.ts integrations/git/__tests__/runPlatformGate.test.ts integrations/lifecycle/__tests__/status.test.ts integrations/lifecycle/__tests__/doctor.test.ts integrations/lifecycle/__tests__/cli.test.ts` => `81 passed, 0 failed`.
|
|
1995
1995
|
- `npm run -s typecheck` => `exit 0`.
|
|
1996
1996
|
|
|
1997
|
-
-
|
|
1997
|
+
- ✅ `P12.F2.T73` Preparar y publicar release con el hardening de `#606`.
|
|
1998
|
+
- cierre ejecutado:
|
|
1999
|
+
- release branch creada: `release/6.3.37`.
|
|
2000
|
+
- versionado aplicado en `package.json`, `package-lock.json`, `VERSION` y release notes.
|
|
2001
|
+
- PR de release mergeada: `#610` (`commit acbdb73`).
|
|
2002
|
+
- publicación npm completada con éxito (`npm publish --access public`).
|
|
2003
|
+
- propagación validada: `latest=6.3.37`.
|
|
2004
|
+
- evidencia:
|
|
2005
|
+
- `npx --yes tsx@4.21.0 --test integrations/gate/__tests__/stagePolicies.test.ts integrations/git/__tests__/runPlatformGate.test.ts integrations/lifecycle/__tests__/status.test.ts integrations/lifecycle/__tests__/doctor.test.ts integrations/lifecycle/__tests__/cli.test.ts` => `81 passed, 0 failed`.
|
|
2006
|
+
- `npm run -s typecheck` => `exit 0`.
|
|
2007
|
+
- `npm view pumuki dist-tags --json` => `"latest": "6.3.37"`.
|
|
2008
|
+
- `npm view pumuki@6.3.37 version` => `6.3.37`.
|
|
2009
|
+
|
|
2010
|
+
- ✅ **Cierre consolidado del bloque P12.F2** (ID interno: `P12.F2.T74`).
|
|
2011
|
+
- cierre ejecutado:
|
|
2012
|
+
- bloque `P12.F2` consolidado con trazabilidad completa (issues/PRs/releases y evidencias de validación).
|
|
2013
|
+
- backlog siguiente preparado en modo standby, sin deuda técnica abierta obligatoria del bloque actual.
|
|
2014
|
+
- confirmada publicación estable `pumuki@6.3.37` como baseline para nuevos proyectos.
|
|
2015
|
+
- evidencia:
|
|
2016
|
+
- `gh issue view 606 --json state` => `CLOSED`.
|
|
2017
|
+
- `gh pr view 608 --json state,mergedAt` => `MERGED`.
|
|
2018
|
+
- `gh pr view 610 --json state,mergedAt` => `MERGED`.
|
|
2019
|
+
- `npm view pumuki dist-tags --json` => `"latest": "6.3.37"`.
|
|
2020
|
+
|
|
2021
|
+
- 🚧 **En espera de nueva orden** (ID interno: `P12.F2.T75`; sin deuda técnica pendiente de este bloque).
|
|
1998
2022
|
- salida esperada:
|
|
1999
|
-
-
|
|
2000
|
-
-
|
|
2023
|
+
- mantener una sola `🚧` activa por política del tablero.
|
|
2024
|
+
- no ejecutar trabajo técnico nuevo hasta instrucción explícita del usuario.
|
|
2001
2025
|
|
|
2002
2026
|
Criterio de salida F6:
|
|
2003
2027
|
- veredicto final trazable y cierre administrativo completo.
|
|
@@ -81,6 +81,12 @@ const DEFAULT_MAX_AGE_SECONDS: Readonly<Record<AiGateStage, number>> = {
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
const DEFAULT_PROTECTED_BRANCHES = new Set(['main', 'master', 'develop', 'dev']);
|
|
84
|
+
const MCP_RECEIPT_STAGE_ORDER: Readonly<Record<AiGateStage, number>> = {
|
|
85
|
+
PRE_WRITE: 0,
|
|
86
|
+
PRE_COMMIT: 1,
|
|
87
|
+
PRE_PUSH: 2,
|
|
88
|
+
CI: 3,
|
|
89
|
+
};
|
|
84
90
|
|
|
85
91
|
const toErrorViolation = (code: string, message: string): AiGateViolation => ({
|
|
86
92
|
code,
|
|
@@ -335,6 +341,13 @@ const toPolicyStage = (stage: AiGateStage): SkillsStage => {
|
|
|
335
341
|
return stage;
|
|
336
342
|
};
|
|
337
343
|
|
|
344
|
+
const isMcpReceiptStageCompatible = (params: {
|
|
345
|
+
receiptStage: AiGateStage;
|
|
346
|
+
requestedStage: AiGateStage;
|
|
347
|
+
}): boolean => {
|
|
348
|
+
return MCP_RECEIPT_STAGE_ORDER[params.receiptStage] >= MCP_RECEIPT_STAGE_ORDER[params.requestedStage];
|
|
349
|
+
};
|
|
350
|
+
|
|
338
351
|
const collectMcpReceiptViolations = (params: {
|
|
339
352
|
required: boolean;
|
|
340
353
|
stage: AiGateStage;
|
|
@@ -405,11 +418,16 @@ const collectMcpReceiptViolations = (params: {
|
|
|
405
418
|
)
|
|
406
419
|
);
|
|
407
420
|
}
|
|
408
|
-
if (
|
|
421
|
+
if (
|
|
422
|
+
!isMcpReceiptStageCompatible({
|
|
423
|
+
receiptStage: receiptRead.receipt.stage,
|
|
424
|
+
requestedStage: params.stage,
|
|
425
|
+
})
|
|
426
|
+
) {
|
|
409
427
|
violations.push(
|
|
410
428
|
toErrorViolation(
|
|
411
429
|
'MCP_ENTERPRISE_RECEIPT_STAGE_MISMATCH',
|
|
412
|
-
`MCP receipt stage mismatch (${receiptRead.receipt.stage}
|
|
430
|
+
`MCP receipt stage mismatch (${receiptRead.receipt.stage} incompatible with ${params.stage}).`
|
|
413
431
|
)
|
|
414
432
|
);
|
|
415
433
|
}
|
|
@@ -2,6 +2,7 @@ import { execFileSync as runBinarySync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import type { Fact } from '../../core/facts/Fact';
|
|
5
|
+
import type { GitChange } from './gitDiffUtils';
|
|
5
6
|
import { parseNameStatus, hasAllowedExtension, buildFactsFromChanges } from './gitDiffUtils';
|
|
6
7
|
export { parseNameStatus } from './gitDiffUtils';
|
|
7
8
|
|
|
@@ -77,7 +78,16 @@ export class GitService implements IGitService {
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
getStagedAndUnstagedFacts(extensions: ReadonlyArray<string>): ReadonlyArray<Fact> {
|
|
80
|
-
const
|
|
81
|
+
const hasHeadCommit = this.hasHeadCommit();
|
|
82
|
+
const trackedNameStatus = hasHeadCommit
|
|
83
|
+
? this.runGit(['diff', '--name-status', 'HEAD'])
|
|
84
|
+
: [
|
|
85
|
+
this.runGit(['diff', '--cached', '--name-status']),
|
|
86
|
+
this.runGit(['diff', '--name-status']),
|
|
87
|
+
]
|
|
88
|
+
.filter((chunk) => chunk.trim().length > 0)
|
|
89
|
+
.join('\n');
|
|
90
|
+
const trackedChanges = this.deduplicateChangesByPath(parseNameStatus(trackedNameStatus))
|
|
81
91
|
.filter((change) => hasAllowedExtension(change.path, extensions));
|
|
82
92
|
const untrackedPaths = this.runGit(['ls-files', '--others', '--exclude-standard'])
|
|
83
93
|
.split('\n')
|
|
@@ -100,6 +110,23 @@ export class GitService implements IGitService {
|
|
|
100
110
|
);
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
private hasHeadCommit(): boolean {
|
|
114
|
+
try {
|
|
115
|
+
this.runGit(['rev-parse', '--verify', 'HEAD']);
|
|
116
|
+
return true;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private deduplicateChangesByPath(changes: ReadonlyArray<GitChange>): ReadonlyArray<GitChange> {
|
|
123
|
+
const byPath = new Map<string, GitChange>();
|
|
124
|
+
for (const change of changes) {
|
|
125
|
+
byPath.set(change.path, change);
|
|
126
|
+
}
|
|
127
|
+
return Array.from(byPath.values());
|
|
128
|
+
}
|
|
129
|
+
|
|
103
130
|
private readWorkingTreeFile(repoRoot: string, filePath: string): string {
|
|
104
131
|
try {
|
|
105
132
|
return readFileSync(join(repoRoot, filePath), 'utf8');
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { GitService, type IGitService } from './GitService';
|
|
4
|
+
|
|
5
|
+
export type GitAtomicityStage = 'PRE_COMMIT' | 'PRE_PUSH' | 'CI';
|
|
6
|
+
|
|
7
|
+
export type GitAtomicityViolation = {
|
|
8
|
+
code:
|
|
9
|
+
| 'GIT_ATOMICITY_TOO_MANY_FILES'
|
|
10
|
+
| 'GIT_ATOMICITY_TOO_MANY_SCOPES'
|
|
11
|
+
| 'GIT_ATOMICITY_COMMIT_MESSAGE_TRACEABILITY';
|
|
12
|
+
message: string;
|
|
13
|
+
remediation: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type GitAtomicityEvaluation = {
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
allowed: boolean;
|
|
19
|
+
violations: ReadonlyArray<GitAtomicityViolation>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type GitAtomicityConfig = {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
maxFiles: number;
|
|
25
|
+
maxScopes: number;
|
|
26
|
+
enforceCommitMessagePattern: boolean;
|
|
27
|
+
commitMessagePattern: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const ATOMICITY_CONFIG_FILE = '.pumuki/git-atomicity.json';
|
|
31
|
+
const DEFAULT_COMMIT_PATTERN =
|
|
32
|
+
'^(feat|fix|chore|refactor|docs|test|perf|build|ci|revert)(\\([^)]+\\))?:\\s.+$';
|
|
33
|
+
|
|
34
|
+
const defaultConfig: GitAtomicityConfig = {
|
|
35
|
+
enabled: false,
|
|
36
|
+
maxFiles: 25,
|
|
37
|
+
maxScopes: 2,
|
|
38
|
+
enforceCommitMessagePattern: true,
|
|
39
|
+
commitMessagePattern: DEFAULT_COMMIT_PATTERN,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const parseBooleanEnv = (value: string | undefined): boolean | undefined => {
|
|
43
|
+
if (typeof value !== 'string') {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
const normalized = value.trim().toLowerCase();
|
|
47
|
+
if (normalized.length === 0) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const parsePositiveIntegerEnv = (value: string | undefined): number | undefined => {
|
|
60
|
+
if (typeof value !== 'string') {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
64
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
return parsed;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
71
|
+
typeof value === 'object' && value !== null;
|
|
72
|
+
|
|
73
|
+
const isNonEmptyString = (value: unknown): value is string =>
|
|
74
|
+
typeof value === 'string' && value.trim().length > 0;
|
|
75
|
+
|
|
76
|
+
const parseFileConfig = (repoRoot: string): Partial<GitAtomicityConfig> | undefined => {
|
|
77
|
+
const filePath = resolve(repoRoot, ATOMICITY_CONFIG_FILE);
|
|
78
|
+
if (!existsSync(filePath)) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
try {
|
|
82
|
+
const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
83
|
+
if (!isRecord(parsed)) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const fromFile: Partial<GitAtomicityConfig> = {};
|
|
87
|
+
if (typeof parsed.enabled === 'boolean') {
|
|
88
|
+
fromFile.enabled = parsed.enabled;
|
|
89
|
+
}
|
|
90
|
+
if (Number.isInteger(parsed.maxFiles) && Number(parsed.maxFiles) > 0) {
|
|
91
|
+
fromFile.maxFiles = Number(parsed.maxFiles);
|
|
92
|
+
}
|
|
93
|
+
if (Number.isInteger(parsed.maxScopes) && Number(parsed.maxScopes) > 0) {
|
|
94
|
+
fromFile.maxScopes = Number(parsed.maxScopes);
|
|
95
|
+
}
|
|
96
|
+
if (typeof parsed.enforceCommitMessagePattern === 'boolean') {
|
|
97
|
+
fromFile.enforceCommitMessagePattern = parsed.enforceCommitMessagePattern;
|
|
98
|
+
}
|
|
99
|
+
if (isNonEmptyString(parsed.commitMessagePattern)) {
|
|
100
|
+
fromFile.commitMessagePattern = parsed.commitMessagePattern;
|
|
101
|
+
}
|
|
102
|
+
return fromFile;
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const resolveConfig = (repoRoot: string): GitAtomicityConfig => {
|
|
109
|
+
const fromFile = parseFileConfig(repoRoot) ?? {};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
enabled:
|
|
113
|
+
parseBooleanEnv(process.env.PUMUKI_GIT_ATOMICITY_ENABLED)
|
|
114
|
+
?? fromFile.enabled
|
|
115
|
+
?? defaultConfig.enabled,
|
|
116
|
+
maxFiles:
|
|
117
|
+
parsePositiveIntegerEnv(process.env.PUMUKI_GIT_ATOMICITY_MAX_FILES)
|
|
118
|
+
?? fromFile.maxFiles
|
|
119
|
+
?? defaultConfig.maxFiles,
|
|
120
|
+
maxScopes:
|
|
121
|
+
parsePositiveIntegerEnv(process.env.PUMUKI_GIT_ATOMICITY_MAX_SCOPES)
|
|
122
|
+
?? fromFile.maxScopes
|
|
123
|
+
?? defaultConfig.maxScopes,
|
|
124
|
+
enforceCommitMessagePattern:
|
|
125
|
+
parseBooleanEnv(process.env.PUMUKI_GIT_ATOMICITY_ENFORCE_COMMIT_PATTERN)
|
|
126
|
+
?? fromFile.enforceCommitMessagePattern
|
|
127
|
+
?? defaultConfig.enforceCommitMessagePattern,
|
|
128
|
+
commitMessagePattern:
|
|
129
|
+
process.env.PUMUKI_GIT_ATOMICITY_COMMIT_PATTERN?.trim()
|
|
130
|
+
|| fromFile.commitMessagePattern
|
|
131
|
+
|| defaultConfig.commitMessagePattern,
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const parseLines = (value: string): ReadonlyArray<string> =>
|
|
136
|
+
value
|
|
137
|
+
.split('\n')
|
|
138
|
+
.map((line) => line.trim())
|
|
139
|
+
.filter((line) => line.length > 0);
|
|
140
|
+
|
|
141
|
+
const resolveScopeKey = (filePath: string): string => {
|
|
142
|
+
const normalized = filePath.replace(/\\/g, '/').trim();
|
|
143
|
+
const segments = normalized.split('/').filter((segment) => segment.length > 0);
|
|
144
|
+
if (segments.length === 0) {
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
(segments[0] === 'apps' || segments[0] === 'packages' || segments[0] === 'services')
|
|
149
|
+
&& segments.length >= 2
|
|
150
|
+
) {
|
|
151
|
+
return `${segments[0]}/${segments[1]}`;
|
|
152
|
+
}
|
|
153
|
+
return segments[0] ?? '';
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const collectChangedPaths = (params: {
|
|
157
|
+
git: IGitService;
|
|
158
|
+
repoRoot: string;
|
|
159
|
+
stage: GitAtomicityStage;
|
|
160
|
+
fromRef?: string;
|
|
161
|
+
toRef?: string;
|
|
162
|
+
}): ReadonlyArray<string> => {
|
|
163
|
+
if (params.stage === 'PRE_COMMIT') {
|
|
164
|
+
return parseLines(
|
|
165
|
+
params.git.runGit(['diff', '--cached', '--name-only', '--diff-filter=ACMR'], params.repoRoot)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!params.fromRef || !params.toRef) {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
return parseLines(
|
|
172
|
+
params.git.runGit(
|
|
173
|
+
['diff', '--name-only', '--diff-filter=ACMR', `${params.fromRef}..${params.toRef}`],
|
|
174
|
+
params.repoRoot
|
|
175
|
+
)
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const collectCommitSubjects = (params: {
|
|
180
|
+
git: IGitService;
|
|
181
|
+
repoRoot: string;
|
|
182
|
+
fromRef?: string;
|
|
183
|
+
toRef?: string;
|
|
184
|
+
}): ReadonlyArray<string> => {
|
|
185
|
+
if (!params.fromRef || !params.toRef) {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
return parseLines(
|
|
189
|
+
params.git.runGit(['log', '--format=%s', `${params.fromRef}..${params.toRef}`], params.repoRoot)
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const evaluateGitAtomicity = (params: {
|
|
194
|
+
git?: IGitService;
|
|
195
|
+
repoRoot?: string;
|
|
196
|
+
stage: GitAtomicityStage;
|
|
197
|
+
fromRef?: string;
|
|
198
|
+
toRef?: string;
|
|
199
|
+
}): GitAtomicityEvaluation => {
|
|
200
|
+
const git = params.git ?? new GitService();
|
|
201
|
+
const repoRoot = params.repoRoot ? resolve(params.repoRoot) : git.resolveRepoRoot();
|
|
202
|
+
const config = resolveConfig(repoRoot);
|
|
203
|
+
if (!config.enabled) {
|
|
204
|
+
return {
|
|
205
|
+
enabled: false,
|
|
206
|
+
allowed: true,
|
|
207
|
+
violations: [],
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const violations: GitAtomicityViolation[] = [];
|
|
212
|
+
const changedPaths = collectChangedPaths({
|
|
213
|
+
git,
|
|
214
|
+
repoRoot,
|
|
215
|
+
stage: params.stage,
|
|
216
|
+
fromRef: params.fromRef,
|
|
217
|
+
toRef: params.toRef,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (changedPaths.length > config.maxFiles) {
|
|
221
|
+
violations.push({
|
|
222
|
+
code: 'GIT_ATOMICITY_TOO_MANY_FILES',
|
|
223
|
+
message:
|
|
224
|
+
`Git atomicity guard blocked at ${params.stage}: changed_files=${changedPaths.length} exceeds max_files=${config.maxFiles}.`,
|
|
225
|
+
remediation: `Divide los cambios en commits más pequeños (máximo ${config.maxFiles} archivos por commit).`,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const scopeKeys = new Set(
|
|
230
|
+
changedPaths
|
|
231
|
+
.map((filePath) => resolveScopeKey(filePath))
|
|
232
|
+
.filter((scopeKey) => scopeKey.length > 0)
|
|
233
|
+
);
|
|
234
|
+
if (scopeKeys.size > config.maxScopes) {
|
|
235
|
+
violations.push({
|
|
236
|
+
code: 'GIT_ATOMICITY_TOO_MANY_SCOPES',
|
|
237
|
+
message:
|
|
238
|
+
`Git atomicity guard blocked at ${params.stage}: changed_scopes=${scopeKeys.size} exceeds max_scopes=${config.maxScopes}.`,
|
|
239
|
+
remediation: `Agrupa cambios por ámbito funcional (máximo ${config.maxScopes} scopes por commit).`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (config.enforceCommitMessagePattern && params.stage !== 'PRE_COMMIT') {
|
|
244
|
+
let pattern: RegExp;
|
|
245
|
+
try {
|
|
246
|
+
pattern = new RegExp(config.commitMessagePattern);
|
|
247
|
+
} catch {
|
|
248
|
+
pattern = new RegExp(DEFAULT_COMMIT_PATTERN);
|
|
249
|
+
}
|
|
250
|
+
const subjects = collectCommitSubjects({
|
|
251
|
+
git,
|
|
252
|
+
repoRoot,
|
|
253
|
+
fromRef: params.fromRef,
|
|
254
|
+
toRef: params.toRef,
|
|
255
|
+
});
|
|
256
|
+
const invalidSubjects = subjects.filter((subject) => !pattern.test(subject));
|
|
257
|
+
if (invalidSubjects.length > 0) {
|
|
258
|
+
const sample = invalidSubjects.slice(0, 3).join(' | ');
|
|
259
|
+
violations.push({
|
|
260
|
+
code: 'GIT_ATOMICITY_COMMIT_MESSAGE_TRACEABILITY',
|
|
261
|
+
message:
|
|
262
|
+
`Git atomicity guard blocked at ${params.stage}: commit messages without traceable pattern detected (${invalidSubjects.length}). sample=[${sample}].`,
|
|
263
|
+
remediation:
|
|
264
|
+
'Reescribe los commits para cumplir el patrón de trazabilidad configurado (por ejemplo Conventional Commits).',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
enabled: true,
|
|
271
|
+
allowed: violations.length === 0,
|
|
272
|
+
violations,
|
|
273
|
+
};
|
|
274
|
+
};
|
|
@@ -132,6 +132,13 @@ const defaultDependencies: GateDependencies = {
|
|
|
132
132
|
};
|
|
133
133
|
|
|
134
134
|
const resolveCurrentBranch = (git: IGitService, repoRoot: string): string | null => {
|
|
135
|
+
try {
|
|
136
|
+
const symbolicBranch = git.runGit(['symbolic-ref', '--short', 'HEAD'], repoRoot).trim();
|
|
137
|
+
if (symbolicBranch.length > 0) {
|
|
138
|
+
return symbolicBranch;
|
|
139
|
+
}
|
|
140
|
+
} catch {
|
|
141
|
+
}
|
|
135
142
|
try {
|
|
136
143
|
const branch = git.runGit(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot).trim();
|
|
137
144
|
if (branch.length === 0 || branch === 'HEAD') {
|