disensor 0.1.0__tar.gz
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.
- disensor-0.1.0/LICENSE +21 -0
- disensor-0.1.0/PKG-INFO +95 -0
- disensor-0.1.0/README.md +81 -0
- disensor-0.1.0/pyproject.toml +25 -0
- disensor-0.1.0/setup.cfg +4 -0
- disensor-0.1.0/src/disensor/__init__.py +2 -0
- disensor-0.1.0/src/disensor/__main__.py +3 -0
- disensor-0.1.0/src/disensor/cli.py +62 -0
- disensor-0.1.0/src/disensor/gate.py +245 -0
- disensor-0.1.0/src/disensor/plantilla.py +86 -0
- disensor-0.1.0/src/disensor/reglas.py +197 -0
- disensor-0.1.0/src/disensor/render.py +123 -0
- disensor-0.1.0/src/disensor/residuo.schema.json +354 -0
- disensor-0.1.0/src/disensor/vectores.py +189 -0
- disensor-0.1.0/src/disensor.egg-info/PKG-INFO +95 -0
- disensor-0.1.0/src/disensor.egg-info/SOURCES.txt +18 -0
- disensor-0.1.0/src/disensor.egg-info/dependency_links.txt +1 -0
- disensor-0.1.0/src/disensor.egg-info/entry_points.txt +2 -0
- disensor-0.1.0/src/disensor.egg-info/requires.txt +4 -0
- disensor-0.1.0/src/disensor.egg-info/top_level.txt +1 -0
disensor-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nicolas Rocchia
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
disensor-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: disensor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: disensor: adversarial plan & code review with a declared residue. Emite, valida y hace cumplir en CI declaraciones de residuo (esquema residuo/v0.1).
|
|
5
|
+
Author-email: Nicolas Rocchia <nicolasrocchia@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: jsonschema>=4.18
|
|
11
|
+
Provides-Extra: test
|
|
12
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# disensor
|
|
16
|
+
|
|
17
|
+
Adversarial plan & code review with a declared residue.
|
|
18
|
+
|
|
19
|
+
Declaración de residuo de revisión adversarial, con validación y gate de CI. Implementación de referencia del artefacto definido a partir del método de **desacuerdo controlado**: un modelo genera, un modelo de otra familia ataca, el generador verifica cada hallazgo, y el ciclo termina cuando todo hallazgo quedó resuelto, refutado con evidencia o escalado a un humano.
|
|
20
|
+
|
|
21
|
+
El artefacto que este repo define y hace cumplir registra cómo terminó cada evento de revisión: los hallazgos con su estado terminal, y el **residuo**: lo que el ciclo no pudo cerrar por sí mismo y descansa sobre el juicio de alguien. La declaración lista residuo, no cobertura: dirige el escrutinio del revisor humano en lugar de leerse como sello de calidad.
|
|
22
|
+
|
|
23
|
+
Paper del método: Rocchia, N. (2026), *Desacuerdo controlado: revisión adversarial automatizada con un segundo asistente de código en el desarrollo de software*, DOI [10.5281/zenodo.21633495](https://doi.org/10.5281/zenodo.21633495).
|
|
24
|
+
|
|
25
|
+
## Qué hay acá
|
|
26
|
+
|
|
27
|
+
- `spec/residuo.schema.json`: el esquema del artefacto (JSON Schema 2020-12), versión v0.1.
|
|
28
|
+
- `spec/ejemplos/`: tres artefactos de ejemplo, incluido un evento real anonimizado y el perfil minimizado sin texto libre.
|
|
29
|
+
- `src/disensor/`: paquete Python con el validador (reglas R0 a R10), el gate de CI (chequeos G1 a G5), el render del comentario de PR y el scaffolding de artefactos.
|
|
30
|
+
- `action.yml`: GitHub Action compuesta, lista para usar.
|
|
31
|
+
- `docs/integracion-claude-code.md`: cómo el flujo real (Claude Code + Codex) emite el artefacto al cierre de cada evento.
|
|
32
|
+
|
|
33
|
+
## Uso rápido
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install disensor # para desarrollo, desde el repo clonado: pip install -e .
|
|
37
|
+
|
|
38
|
+
disensor nuevo --compuerta diff --nivel B # plantilla prellenada en .residuo/
|
|
39
|
+
disensor validar .residuo/<id>.json # schema + reglas R0 a R10
|
|
40
|
+
disensor gate --sin-comentario # lo que va a correr CI, en local
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
En el repositorio consumidor, `disensor.config.json` en la raíz declara el nivel de criticidad (el nivel viaja con el código, en un archivo versionado):
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"nivel_criticidad": "B",
|
|
48
|
+
"nivel_A_habilitado": false
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Y el workflow (ver `docs/ejemplo-workflow.yml`):
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
on: pull_request
|
|
56
|
+
permissions:
|
|
57
|
+
contents: read
|
|
58
|
+
pull-requests: write
|
|
59
|
+
jobs:
|
|
60
|
+
gate:
|
|
61
|
+
runs-on: ubuntu-latest
|
|
62
|
+
steps:
|
|
63
|
+
- uses: actions/checkout@v4
|
|
64
|
+
with:
|
|
65
|
+
fetch-depth: 0
|
|
66
|
+
- uses: NicolasRocchia/disensor@v0.1.0
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
El gate valida todos los artefactos de `.residuo/` del PR, aplica la política y publica la declaración como comentario (se actualiza en el lugar en cada push).
|
|
70
|
+
|
|
71
|
+
## Qué hace cumplir el gate
|
|
72
|
+
|
|
73
|
+
Por artefacto (reglas R0 a R10): coherencia entre hallazgos y residuo, conteos que cierran, decorrelación de familias entre generador y revisor, evidencia obligatoria en refutaciones verificables, atención humana obligatoria en refutaciones interpretativas, corrección verificada antes de cerrar un hallazgo en compuerta de diff, rechazo de marcadores genéricos, y perfil minimizado sin fugas de texto.
|
|
74
|
+
|
|
75
|
+
Por PR (chequeos G1 a G5): al menos una declaración válida en el rango, nivel del artefacto igual al declarado del repositorio, Nivel A bloqueado mientras la gobernanza no esté validada, política de confinamiento del revisor por nivel, y commit revisado dentro del rango del PR.
|
|
76
|
+
|
|
77
|
+
Límite honesto, heredado del protocolo: la máquina detecta el campo vacío y el marcador genérico, no la declaración falsa. El muestreo humano de PR cerrados sigue siendo la única defensa real contra el cumplimiento cosmético.
|
|
78
|
+
|
|
79
|
+
## Qué no hace
|
|
80
|
+
|
|
81
|
+
No corre modelos, no pide claves de API en CI, y ningún código viaja a ningún servicio: valida un JSON que ya está versionado en el repo. La orquestación del loop vive donde el equipo ya trabaja; el perfil `minimizado` del artefacto permite ambientes donde ni siquiera el texto de los hallazgos puede salir del entorno.
|
|
82
|
+
|
|
83
|
+
## Conformidad entre implementaciones
|
|
84
|
+
|
|
85
|
+
`spec/vectores/` contiene los vectores de conformidad: 22 artefactos con su veredicto esperado (valido o no, y las etiquetas de regla que deben dispararse). Toda implementacion del validador tiene que pasarlos identicos: la referencia en Python los corre en la suite (`tests/test_vectores.py`) y el port TypeScript del plano de evidencia los corre con `npm run conformidad`. Se comparan etiquetas, no mensajes. Los vectores se regeneran con `python -m disensor.vectores spec/vectores`.
|
|
86
|
+
|
|
87
|
+
`plano-evidencia/` contiene el Worker de ingesta (Cloudflare Workers mas D1) con el port TypeScript del validador y el recibo de integridad de solo agregado. Ver su README para el estado de verificacion y el despliegue.
|
|
88
|
+
|
|
89
|
+
## Estado
|
|
90
|
+
|
|
91
|
+
v0.1, borrador en uso. El esquema puede cambiar hasta v1.0; los cambios se declaran en el propio esquema. Dos decisiones abiertas antes de v0.2: claves del esquema en español o inglés, y licencia definitiva (hoy MIT; Apache-2.0 está en consideración por la concesión de patentes antes del release público).
|
|
92
|
+
|
|
93
|
+
## Licencia
|
|
94
|
+
|
|
95
|
+
MIT.
|
disensor-0.1.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# disensor
|
|
2
|
+
|
|
3
|
+
Adversarial plan & code review with a declared residue.
|
|
4
|
+
|
|
5
|
+
Declaración de residuo de revisión adversarial, con validación y gate de CI. Implementación de referencia del artefacto definido a partir del método de **desacuerdo controlado**: un modelo genera, un modelo de otra familia ataca, el generador verifica cada hallazgo, y el ciclo termina cuando todo hallazgo quedó resuelto, refutado con evidencia o escalado a un humano.
|
|
6
|
+
|
|
7
|
+
El artefacto que este repo define y hace cumplir registra cómo terminó cada evento de revisión: los hallazgos con su estado terminal, y el **residuo**: lo que el ciclo no pudo cerrar por sí mismo y descansa sobre el juicio de alguien. La declaración lista residuo, no cobertura: dirige el escrutinio del revisor humano en lugar de leerse como sello de calidad.
|
|
8
|
+
|
|
9
|
+
Paper del método: Rocchia, N. (2026), *Desacuerdo controlado: revisión adversarial automatizada con un segundo asistente de código en el desarrollo de software*, DOI [10.5281/zenodo.21633495](https://doi.org/10.5281/zenodo.21633495).
|
|
10
|
+
|
|
11
|
+
## Qué hay acá
|
|
12
|
+
|
|
13
|
+
- `spec/residuo.schema.json`: el esquema del artefacto (JSON Schema 2020-12), versión v0.1.
|
|
14
|
+
- `spec/ejemplos/`: tres artefactos de ejemplo, incluido un evento real anonimizado y el perfil minimizado sin texto libre.
|
|
15
|
+
- `src/disensor/`: paquete Python con el validador (reglas R0 a R10), el gate de CI (chequeos G1 a G5), el render del comentario de PR y el scaffolding de artefactos.
|
|
16
|
+
- `action.yml`: GitHub Action compuesta, lista para usar.
|
|
17
|
+
- `docs/integracion-claude-code.md`: cómo el flujo real (Claude Code + Codex) emite el artefacto al cierre de cada evento.
|
|
18
|
+
|
|
19
|
+
## Uso rápido
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install disensor # para desarrollo, desde el repo clonado: pip install -e .
|
|
23
|
+
|
|
24
|
+
disensor nuevo --compuerta diff --nivel B # plantilla prellenada en .residuo/
|
|
25
|
+
disensor validar .residuo/<id>.json # schema + reglas R0 a R10
|
|
26
|
+
disensor gate --sin-comentario # lo que va a correr CI, en local
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
En el repositorio consumidor, `disensor.config.json` en la raíz declara el nivel de criticidad (el nivel viaja con el código, en un archivo versionado):
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"nivel_criticidad": "B",
|
|
34
|
+
"nivel_A_habilitado": false
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Y el workflow (ver `docs/ejemplo-workflow.yml`):
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
on: pull_request
|
|
42
|
+
permissions:
|
|
43
|
+
contents: read
|
|
44
|
+
pull-requests: write
|
|
45
|
+
jobs:
|
|
46
|
+
gate:
|
|
47
|
+
runs-on: ubuntu-latest
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v4
|
|
50
|
+
with:
|
|
51
|
+
fetch-depth: 0
|
|
52
|
+
- uses: NicolasRocchia/disensor@v0.1.0
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
El gate valida todos los artefactos de `.residuo/` del PR, aplica la política y publica la declaración como comentario (se actualiza en el lugar en cada push).
|
|
56
|
+
|
|
57
|
+
## Qué hace cumplir el gate
|
|
58
|
+
|
|
59
|
+
Por artefacto (reglas R0 a R10): coherencia entre hallazgos y residuo, conteos que cierran, decorrelación de familias entre generador y revisor, evidencia obligatoria en refutaciones verificables, atención humana obligatoria en refutaciones interpretativas, corrección verificada antes de cerrar un hallazgo en compuerta de diff, rechazo de marcadores genéricos, y perfil minimizado sin fugas de texto.
|
|
60
|
+
|
|
61
|
+
Por PR (chequeos G1 a G5): al menos una declaración válida en el rango, nivel del artefacto igual al declarado del repositorio, Nivel A bloqueado mientras la gobernanza no esté validada, política de confinamiento del revisor por nivel, y commit revisado dentro del rango del PR.
|
|
62
|
+
|
|
63
|
+
Límite honesto, heredado del protocolo: la máquina detecta el campo vacío y el marcador genérico, no la declaración falsa. El muestreo humano de PR cerrados sigue siendo la única defensa real contra el cumplimiento cosmético.
|
|
64
|
+
|
|
65
|
+
## Qué no hace
|
|
66
|
+
|
|
67
|
+
No corre modelos, no pide claves de API en CI, y ningún código viaja a ningún servicio: valida un JSON que ya está versionado en el repo. La orquestación del loop vive donde el equipo ya trabaja; el perfil `minimizado` del artefacto permite ambientes donde ni siquiera el texto de los hallazgos puede salir del entorno.
|
|
68
|
+
|
|
69
|
+
## Conformidad entre implementaciones
|
|
70
|
+
|
|
71
|
+
`spec/vectores/` contiene los vectores de conformidad: 22 artefactos con su veredicto esperado (valido o no, y las etiquetas de regla que deben dispararse). Toda implementacion del validador tiene que pasarlos identicos: la referencia en Python los corre en la suite (`tests/test_vectores.py`) y el port TypeScript del plano de evidencia los corre con `npm run conformidad`. Se comparan etiquetas, no mensajes. Los vectores se regeneran con `python -m disensor.vectores spec/vectores`.
|
|
72
|
+
|
|
73
|
+
`plano-evidencia/` contiene el Worker de ingesta (Cloudflare Workers mas D1) con el port TypeScript del validador y el recibo de integridad de solo agregado. Ver su README para el estado de verificacion y el despliegue.
|
|
74
|
+
|
|
75
|
+
## Estado
|
|
76
|
+
|
|
77
|
+
v0.1, borrador en uso. El esquema puede cambiar hasta v1.0; los cambios se declaran en el propio esquema. Dos decisiones abiertas antes de v0.2: claves del esquema en español o inglés, y licencia definitiva (hoy MIT; Apache-2.0 está en consideración por la concesión de patentes antes del release público).
|
|
78
|
+
|
|
79
|
+
## Licencia
|
|
80
|
+
|
|
81
|
+
MIT.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "disensor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "disensor: adversarial plan & code review with a declared residue. Emite, valida y hace cumplir en CI declaraciones de residuo (esquema residuo/v0.1)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Nicolas Rocchia", email = "nicolasrocchia@gmail.com" }]
|
|
13
|
+
dependencies = ["jsonschema>=4.18"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = ["pytest>=8"]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
disensor = "disensor.cli:main"
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["src"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.package-data]
|
|
25
|
+
disensor = ["residuo.schema.json"]
|
disensor-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Interfaz de linea de comandos: disensor {nuevo, validar, gate}."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .gate import main_gate
|
|
10
|
+
from .plantilla import main_nuevo
|
|
11
|
+
from .reglas import cargar_schema, validar_artefacto
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main_validar(args) -> int:
|
|
15
|
+
schema = cargar_schema()
|
|
16
|
+
fallo = False
|
|
17
|
+
for ruta in args.archivos:
|
|
18
|
+
with open(ruta, encoding="utf-8") as f:
|
|
19
|
+
artefacto = json.load(f)
|
|
20
|
+
errores = validar_artefacto(artefacto, schema)
|
|
21
|
+
print(f"{ruta}: {'VALIDO' if not errores else 'INVALIDO'}")
|
|
22
|
+
for msg in errores:
|
|
23
|
+
print(f" {msg}")
|
|
24
|
+
fallo = fallo or bool(errores)
|
|
25
|
+
return 1 if fallo else 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def construir_parser() -> argparse.ArgumentParser:
|
|
29
|
+
p = argparse.ArgumentParser(
|
|
30
|
+
prog="disensor",
|
|
31
|
+
description="Declaracion de residuo de revision adversarial (desacuerdo controlado).",
|
|
32
|
+
)
|
|
33
|
+
sub = p.add_subparsers(dest="comando", required=True)
|
|
34
|
+
|
|
35
|
+
nuevo = sub.add_parser("nuevo", help="Crea una plantilla de artefacto para el evento en curso.")
|
|
36
|
+
nuevo.add_argument("--directorio", default=".residuo")
|
|
37
|
+
nuevo.add_argument("--compuerta", choices=["plan", "diff", "arquitectura"], default="diff")
|
|
38
|
+
nuevo.add_argument("--nivel", choices=["A", "B", "C"], default="B")
|
|
39
|
+
nuevo.add_argument("--perfil", choices=["completo", "minimizado"], default="completo")
|
|
40
|
+
nuevo.set_defaults(func=main_nuevo)
|
|
41
|
+
|
|
42
|
+
validar = sub.add_parser("validar", help="Valida artefactos contra el schema y las reglas R0 a R10.")
|
|
43
|
+
validar.add_argument("archivos", nargs="+")
|
|
44
|
+
validar.set_defaults(func=main_validar)
|
|
45
|
+
|
|
46
|
+
gate = sub.add_parser("gate", help="Gate de CI: valida el PR completo y aplica politica G1 a G5.")
|
|
47
|
+
gate.add_argument("--directorio", default=".residuo")
|
|
48
|
+
gate.add_argument("--config", default="disensor.config.json")
|
|
49
|
+
gate.add_argument("--base", default=None, help="SHA base del PR (por defecto, el evento de GitHub).")
|
|
50
|
+
gate.add_argument("--cabeza", default=None, help="SHA cabeza del PR (por defecto, el evento de GitHub).")
|
|
51
|
+
gate.add_argument("--sin-comentario", action="store_true", help="No publicar comentario en el PR.")
|
|
52
|
+
gate.set_defaults(func=main_gate)
|
|
53
|
+
return p
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> None:
|
|
57
|
+
args = construir_parser().parse_args()
|
|
58
|
+
sys.exit(args.func(args))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
main()
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Gate de CI: valida las declaraciones de residuo de un PR y aplica politica.
|
|
2
|
+
|
|
3
|
+
Chequeos del gate (sobre los R0 a R10 por artefacto de reglas.py):
|
|
4
|
+
G1: al menos un artefacto valido en el rango del PR, salvo que la
|
|
5
|
+
configuracion declare el gate como no requerido (tipico en Nivel C).
|
|
6
|
+
G2: el nivel del artefacto coincide con el nivel declarado del repositorio
|
|
7
|
+
(seccion 12.2: nivel de criticidad declarado en un archivo del repo).
|
|
8
|
+
G3: Nivel A bloqueado mientras la gobernanza no este validada
|
|
9
|
+
(seccion 3.1: el Nivel A no arranca hasta que la seccion 10 este validada).
|
|
10
|
+
G4: politica de confinamiento por nivel (seccion 10: el revisor solo lee,
|
|
11
|
+
y eso se garantiza con permisos y no con la consigna).
|
|
12
|
+
G5: el commit revisado pertenece al rango del PR.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import urllib.request
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from .reglas import cargar_schema, validar_artefacto
|
|
24
|
+
from .render import MARCADOR, render_comentario
|
|
25
|
+
|
|
26
|
+
CONFIG_DEFECTO = {
|
|
27
|
+
"nivel_criticidad": "B",
|
|
28
|
+
"nivel_A_habilitado": False,
|
|
29
|
+
"gate": {
|
|
30
|
+
"requerido": True,
|
|
31
|
+
"confinamiento_aceptado_nivel_A": ["permisos", "sandbox"],
|
|
32
|
+
"advertir_confinamiento_no_verificado": True,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cargar_config(ruta: Path) -> dict:
|
|
38
|
+
if not ruta.exists():
|
|
39
|
+
return dict(CONFIG_DEFECTO)
|
|
40
|
+
with ruta.open(encoding="utf-8") as f:
|
|
41
|
+
config = json.load(f)
|
|
42
|
+
combinada = dict(CONFIG_DEFECTO)
|
|
43
|
+
combinada.update({k: v for k, v in config.items() if k != "gate"})
|
|
44
|
+
gate = dict(CONFIG_DEFECTO["gate"])
|
|
45
|
+
gate.update(config.get("gate", {}))
|
|
46
|
+
combinada["gate"] = gate
|
|
47
|
+
return combinada
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def rango_del_pr(base: str | None, cabeza: str | None) -> tuple[str | None, str | None]:
|
|
51
|
+
"""Base y cabeza del PR: flags primero, despues el evento de GitHub."""
|
|
52
|
+
if base and cabeza:
|
|
53
|
+
return base, cabeza
|
|
54
|
+
ruta_evento = os.environ.get("GITHUB_EVENT_PATH")
|
|
55
|
+
if ruta_evento and Path(ruta_evento).exists():
|
|
56
|
+
with open(ruta_evento, encoding="utf-8") as f:
|
|
57
|
+
evento = json.load(f)
|
|
58
|
+
pr = evento.get("pull_request") or {}
|
|
59
|
+
return (
|
|
60
|
+
base or (pr.get("base") or {}).get("sha"),
|
|
61
|
+
cabeza or (pr.get("head") or {}).get("sha"),
|
|
62
|
+
)
|
|
63
|
+
return base, cabeza
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def commits_del_rango(base: str, cabeza: str, cwd: Path) -> list[str] | None:
|
|
67
|
+
"""Commits del rango base..cabeza, incluida la cabeza.
|
|
68
|
+
|
|
69
|
+
Devuelve None si git no puede resolver el rango (clon superficial, shas
|
|
70
|
+
desconocidos): eso es una advertencia. Una lista vacia es un resultado
|
|
71
|
+
valido y significa que el rango no contiene commits: G5 debe evaluarse
|
|
72
|
+
igual, porque un rango vacio no puede contener el commit revisado.
|
|
73
|
+
"""
|
|
74
|
+
r = subprocess.run(
|
|
75
|
+
["git", "rev-list", f"{base}..{cabeza}"],
|
|
76
|
+
capture_output=True, text=True, cwd=cwd, check=False,
|
|
77
|
+
)
|
|
78
|
+
if r.returncode != 0:
|
|
79
|
+
return None
|
|
80
|
+
return [linea.strip() for linea in r.stdout.splitlines() if linea.strip()]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def numero_de_pr() -> int | None:
|
|
84
|
+
ruta_evento = os.environ.get("GITHUB_EVENT_PATH")
|
|
85
|
+
if ruta_evento and Path(ruta_evento).exists():
|
|
86
|
+
with open(ruta_evento, encoding="utf-8") as f:
|
|
87
|
+
evento = json.load(f)
|
|
88
|
+
pr = evento.get("pull_request") or {}
|
|
89
|
+
return pr.get("number")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def publicar_comentario(cuerpo: str) -> str:
|
|
94
|
+
"""Crea o actualiza el comentario del gate en el PR. Devuelve un estado legible."""
|
|
95
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
96
|
+
repo = os.environ.get("GITHUB_REPOSITORY")
|
|
97
|
+
pr = numero_de_pr()
|
|
98
|
+
if not (token and repo and pr):
|
|
99
|
+
return "sin token, repositorio o numero de PR: comentario no publicado"
|
|
100
|
+
api = os.environ.get("GITHUB_API_URL", "https://api.github.com")
|
|
101
|
+
cabeceras = {
|
|
102
|
+
"Authorization": f"Bearer {token}",
|
|
103
|
+
"Accept": "application/vnd.github+json",
|
|
104
|
+
"User-Agent": "disensor-gate",
|
|
105
|
+
}
|
|
106
|
+
try:
|
|
107
|
+
req = urllib.request.Request(
|
|
108
|
+
f"{api}/repos/{repo}/issues/{pr}/comments?per_page=100", headers=cabeceras
|
|
109
|
+
)
|
|
110
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
111
|
+
comentarios = json.load(resp)
|
|
112
|
+
existente = next((c for c in comentarios if MARCADOR in (c.get("body") or "")), None)
|
|
113
|
+
datos = json.dumps({"body": cuerpo}).encode("utf-8")
|
|
114
|
+
if existente:
|
|
115
|
+
req = urllib.request.Request(
|
|
116
|
+
f"{api}/repos/{repo}/issues/comments/{existente['id']}",
|
|
117
|
+
data=datos, headers=cabeceras, method="PATCH",
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
req = urllib.request.Request(
|
|
121
|
+
f"{api}/repos/{repo}/issues/{pr}/comments",
|
|
122
|
+
data=datos, headers=cabeceras, method="POST",
|
|
123
|
+
)
|
|
124
|
+
with urllib.request.urlopen(req, timeout=30):
|
|
125
|
+
pass
|
|
126
|
+
return "comentario actualizado" if existente else "comentario publicado"
|
|
127
|
+
except Exception as exc: # el gate no debe caerse por la red
|
|
128
|
+
return f"no se pudo publicar el comentario: {exc}"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def escribir_resumen(cuerpo: str) -> None:
|
|
132
|
+
ruta = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
133
|
+
if ruta:
|
|
134
|
+
with open(ruta, "a", encoding="utf-8") as f:
|
|
135
|
+
f.write(cuerpo + "\n")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def correr_gate(directorio: Path, config_ruta: Path, base: str | None,
|
|
139
|
+
cabeza: str | None, repo_dir: Path, publicar: bool = True) -> int:
|
|
140
|
+
config = cargar_config(config_ruta)
|
|
141
|
+
schema = cargar_schema()
|
|
142
|
+
gate_cfg = config["gate"]
|
|
143
|
+
|
|
144
|
+
errores_gate: list[str] = []
|
|
145
|
+
advertencias: list[str] = []
|
|
146
|
+
errores_por_archivo: dict[str, list[str]] = {}
|
|
147
|
+
validos: list[dict] = []
|
|
148
|
+
|
|
149
|
+
base, cabeza = rango_del_pr(base, cabeza)
|
|
150
|
+
commits = commits_del_rango(base, cabeza, repo_dir) if (base and cabeza) else None
|
|
151
|
+
if commits is None and base and cabeza:
|
|
152
|
+
advertencias.append(
|
|
153
|
+
"no se pudo resolver el rango de commits (checkout con fetch-depth 0): G5 no se evalua"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
archivos = sorted(p for p in directorio.glob("*.json")) if directorio.exists() else []
|
|
157
|
+
for ruta in archivos:
|
|
158
|
+
with ruta.open(encoding="utf-8") as f:
|
|
159
|
+
try:
|
|
160
|
+
artefacto = json.load(f)
|
|
161
|
+
except json.JSONDecodeError as exc:
|
|
162
|
+
errores_por_archivo[ruta.name] = [f"JSON invalido: {exc}"]
|
|
163
|
+
continue
|
|
164
|
+
errores = validar_artefacto(artefacto, schema)
|
|
165
|
+
if errores:
|
|
166
|
+
errores_por_archivo[ruta.name] = errores
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
ev = artefacto["evento"]
|
|
170
|
+
propios: list[str] = []
|
|
171
|
+
|
|
172
|
+
# G2: nivel del artefacto contra nivel declarado del repositorio.
|
|
173
|
+
if ev["nivel_criticidad"] != config["nivel_criticidad"]:
|
|
174
|
+
propios.append(
|
|
175
|
+
f"[G2] nivel del artefacto ({ev['nivel_criticidad']}) distinto del declarado "
|
|
176
|
+
f"en el repositorio ({config['nivel_criticidad']})"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# G3: Nivel A bloqueado hasta validar la gobernanza (seccion 3.1).
|
|
180
|
+
if ev["nivel_criticidad"] == "A" and not config.get("nivel_A_habilitado", False):
|
|
181
|
+
propios.append(
|
|
182
|
+
"[G3] Nivel A bloqueado: la gobernanza de datos (seccion 10) no esta validada "
|
|
183
|
+
"en este repositorio (nivel_A_habilitado=false). Aplicar el protocolo en Nivel A "
|
|
184
|
+
"en esta situacion no es cumplirlo a medias: es violarlo."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# G4: politica de confinamiento por nivel.
|
|
188
|
+
for r in artefacto["actores"]["revisores"]:
|
|
189
|
+
conf = r["confinamiento"]
|
|
190
|
+
if ev["nivel_criticidad"] == "A" and conf["modo"] not in gate_cfg["confinamiento_aceptado_nivel_A"]:
|
|
191
|
+
propios.append(
|
|
192
|
+
f"[G4] revisor {r['id_revisor']}: confinamiento '{conf['modo']}' no admitido en Nivel A "
|
|
193
|
+
f"(el revisor solo lee, y eso se garantiza con permisos y no con la consigna)"
|
|
194
|
+
)
|
|
195
|
+
if not conf["verificado"] and gate_cfg.get("advertir_confinamiento_no_verificado", True):
|
|
196
|
+
advertencias.append(
|
|
197
|
+
f"{ruta.name}: confinamiento del revisor {r['id_revisor']} sin verificacion posterior"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# G5: el commit revisado pertenece al rango del PR.
|
|
201
|
+
if commits is not None:
|
|
202
|
+
cc = ev["commit_cabeza"]
|
|
203
|
+
if not any(c.startswith(cc) or cc.startswith(c) for c in commits):
|
|
204
|
+
propios.append(
|
|
205
|
+
f"[G5] commit revisado {cc} fuera del rango del PR ({base[:7]}..{cabeza[:7]})"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
if propios:
|
|
209
|
+
errores_por_archivo[ruta.name] = propios
|
|
210
|
+
else:
|
|
211
|
+
validos.append(artefacto)
|
|
212
|
+
|
|
213
|
+
# G1: al menos un artefacto valido, salvo gate no requerido.
|
|
214
|
+
if gate_cfg.get("requerido", True) and not validos:
|
|
215
|
+
errores_gate.append(
|
|
216
|
+
"[G1] ningun artefacto de residuo valido para este PR "
|
|
217
|
+
f"(directorio '{directorio}'). La declaracion lista residuo concreto o dice "
|
|
218
|
+
"explicitamente que no hubo ninguno; un campo vacio no es una declaracion."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
cuerpo = render_comentario(validos, errores_por_archivo, errores_gate)
|
|
222
|
+
if advertencias:
|
|
223
|
+
cuerpo += "\n\nAdvertencias:\n" + "\n".join(f"- {a}" for a in advertencias)
|
|
224
|
+
|
|
225
|
+
escribir_resumen(cuerpo)
|
|
226
|
+
if publicar:
|
|
227
|
+
estado = publicar_comentario(cuerpo)
|
|
228
|
+
print(f"[gate] {estado}")
|
|
229
|
+
|
|
230
|
+
fallo = bool(errores_gate or errores_por_archivo)
|
|
231
|
+
print(cuerpo)
|
|
232
|
+
print(f"\n[gate] {'FALLO' if fallo else 'OK'}: {len(validos)} artefacto(s) valido(s), "
|
|
233
|
+
f"{len(errores_por_archivo)} con errores, {len(errores_gate)} error(es) globales")
|
|
234
|
+
return 1 if fallo else 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main_gate(args) -> int:
|
|
238
|
+
return correr_gate(
|
|
239
|
+
directorio=Path(args.directorio),
|
|
240
|
+
config_ruta=Path(args.config),
|
|
241
|
+
base=args.base,
|
|
242
|
+
cabeza=args.cabeza,
|
|
243
|
+
repo_dir=Path.cwd(),
|
|
244
|
+
publicar=not args.sin_comentario,
|
|
245
|
+
)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Scaffolding de un artefacto nuevo: `disensor nuevo`.
|
|
2
|
+
|
|
3
|
+
Genera una plantilla prellenada con lo que git ya sabe (repositorio, commits,
|
|
4
|
+
timestamp) y marcadores que no pasan la validacion hasta completarse. Una
|
|
5
|
+
plantilla que valida vacia seria cumplimiento cosmetico de fabrica.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime
|
|
10
|
+
import json
|
|
11
|
+
import subprocess
|
|
12
|
+
import uuid
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _git(args: list[str], cwd: Path) -> str:
|
|
17
|
+
r = subprocess.run(["git", *args], capture_output=True, text=True, cwd=cwd, check=False)
|
|
18
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def plantilla(compuerta: str, nivel: str, perfil: str, cwd: Path) -> dict:
|
|
22
|
+
ahora = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
23
|
+
remoto = _git(["config", "--get", "remote.origin.url"], cwd) or "COMPLETAR_repositorio"
|
|
24
|
+
cabeza = _git(["rev-parse", "HEAD"], cwd) or "COMPLETAR"
|
|
25
|
+
base = _git(["merge-base", "HEAD", "origin/main"], cwd) or _git(
|
|
26
|
+
["merge-base", "HEAD", "origin/master"], cwd
|
|
27
|
+
)
|
|
28
|
+
a: dict = {
|
|
29
|
+
"esquema": "residuo/v0.1",
|
|
30
|
+
"perfil": perfil,
|
|
31
|
+
"evento": {
|
|
32
|
+
"id_evento": str(uuid.uuid4()),
|
|
33
|
+
"creado_en": ahora,
|
|
34
|
+
"repositorio": remoto,
|
|
35
|
+
"commit_cabeza": cabeza,
|
|
36
|
+
"compuerta": compuerta,
|
|
37
|
+
"nivel_criticidad": nivel,
|
|
38
|
+
"ruta_abreviada": {"usada": False},
|
|
39
|
+
},
|
|
40
|
+
"actores": {
|
|
41
|
+
"generador": {"familia": "anthropic", "modelo": "claude-code"},
|
|
42
|
+
"revisores": [
|
|
43
|
+
{
|
|
44
|
+
"id_revisor": "r1",
|
|
45
|
+
"familia": "openai",
|
|
46
|
+
"modelo": "COMPLETAR_modelo_del_revisor",
|
|
47
|
+
"confinamiento": {
|
|
48
|
+
"modo": "solo_lectura_por_instruccion",
|
|
49
|
+
"verificado": False,
|
|
50
|
+
"metodo_verificacion": "git_status_limpio",
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
],
|
|
54
|
+
"arbitro_humano": {"presente": True},
|
|
55
|
+
},
|
|
56
|
+
"hallazgos": [],
|
|
57
|
+
"residuo": {
|
|
58
|
+
"ausencia_declarada": True,
|
|
59
|
+
"declaracion": "COMPLETAR: declaracion expresa de ausencia, o reemplazar este objeto por items",
|
|
60
|
+
},
|
|
61
|
+
"metricas": {
|
|
62
|
+
"conteos": {
|
|
63
|
+
"total_hallazgos": 0,
|
|
64
|
+
"validos": {"incorporado": 0, "deuda_registrada": 0, "decision_del_dueno": 0},
|
|
65
|
+
"falsos_positivos": {"refutado_verificable": 0, "refutado_interpretativo": 0},
|
|
66
|
+
"escalados_abiertos": 0,
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
if base:
|
|
71
|
+
a["evento"]["commit_base"] = base
|
|
72
|
+
return a
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main_nuevo(args) -> int:
|
|
76
|
+
directorio = Path(args.directorio)
|
|
77
|
+
directorio.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
a = plantilla(args.compuerta, args.nivel, args.perfil, Path.cwd())
|
|
79
|
+
ruta = directorio / f"{a['evento']['id_evento']}.json"
|
|
80
|
+
with ruta.open("w", encoding="utf-8") as f:
|
|
81
|
+
json.dump(a, f, ensure_ascii=False, indent=2)
|
|
82
|
+
f.write("\n")
|
|
83
|
+
print(f"Plantilla creada: {ruta}")
|
|
84
|
+
print("Completar los campos COMPLETAR_ y los hallazgos del evento; despues: disensor validar", ruta)
|
|
85
|
+
print("Nota: la plantilla no valida hasta completarse. Es intencional.")
|
|
86
|
+
return 0
|