disensor 0.1.0__tar.gz → 0.2.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.2.0/PKG-INFO +127 -0
- disensor-0.2.0/README.md +113 -0
- {disensor-0.1.0 → disensor-0.2.0}/pyproject.toml +3 -3
- disensor-0.2.0/src/disensor/__init__.py +2 -0
- disensor-0.2.0/src/disensor/cli.py +79 -0
- disensor-0.2.0/src/disensor/gate.py +257 -0
- disensor-0.2.0/src/disensor/init.py +165 -0
- disensor-0.2.0/src/disensor/render.py +123 -0
- disensor-0.2.0/src/disensor/residue.schema.json +354 -0
- disensor-0.2.0/src/disensor/rules.py +209 -0
- disensor-0.2.0/src/disensor/template.py +87 -0
- disensor-0.2.0/src/disensor/vectors.py +189 -0
- disensor-0.2.0/src/disensor.egg-info/PKG-INFO +127 -0
- {disensor-0.1.0 → disensor-0.2.0}/src/disensor.egg-info/SOURCES.txt +9 -5
- disensor-0.2.0/tests/test_init.py +93 -0
- disensor-0.2.0/tests/test_rules.py +218 -0
- disensor-0.2.0/tests/test_vectors.py +25 -0
- disensor-0.1.0/PKG-INFO +0 -95
- disensor-0.1.0/README.md +0 -81
- disensor-0.1.0/src/disensor/__init__.py +0 -2
- disensor-0.1.0/src/disensor/cli.py +0 -62
- disensor-0.1.0/src/disensor/gate.py +0 -245
- disensor-0.1.0/src/disensor/plantilla.py +0 -86
- disensor-0.1.0/src/disensor/reglas.py +0 -197
- disensor-0.1.0/src/disensor/render.py +0 -123
- disensor-0.1.0/src/disensor/residuo.schema.json +0 -354
- disensor-0.1.0/src/disensor/vectores.py +0 -189
- disensor-0.1.0/src/disensor.egg-info/PKG-INFO +0 -95
- {disensor-0.1.0 → disensor-0.2.0}/LICENSE +0 -0
- {disensor-0.1.0 → disensor-0.2.0}/setup.cfg +0 -0
- {disensor-0.1.0 → disensor-0.2.0}/src/disensor/__main__.py +0 -0
- {disensor-0.1.0 → disensor-0.2.0}/src/disensor.egg-info/dependency_links.txt +0 -0
- {disensor-0.1.0 → disensor-0.2.0}/src/disensor.egg-info/entry_points.txt +0 -0
- {disensor-0.1.0 → disensor-0.2.0}/src/disensor.egg-info/requires.txt +0 -0
- {disensor-0.1.0 → disensor-0.2.0}/src/disensor.egg-info/top_level.txt +0 -0
disensor-0.2.0/PKG-INFO
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: disensor
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Adversarial plan & code review with a declared residue. Emits, validates and CI-enforces residue declarations (residue/v0.2 schema).
|
|
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
|
+
**English**: disensor emits, validates and CI-enforces *residue declarations*: a JSON artifact that records how each adversarial review event ended (one model generates, a model from another family attacks, every finding reaches a terminal state) and, above all, what the cycle could NOT close by itself. Install with `pip install disensor`, scaffold a repo with `disensor init`. As of v0.2 the whole contract (schema keys, enums, CLI) is English; the docs below are in Spanish, and the ES-EN glossary at the end maps the paper's terminology to the schema.
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
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).
|
|
26
|
+
|
|
27
|
+
## Qué hay acá
|
|
28
|
+
|
|
29
|
+
- `spec/residue.schema.json`: el esquema del artefacto (JSON Schema 2020-12), versión residue/v0.2.
|
|
30
|
+
- `spec/examples/`: tres artefactos de ejemplo, incluido un evento real anonimizado y el perfil minimizado sin texto libre.
|
|
31
|
+
- `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, el scaffolding de artefactos y el de repositorios (`init`).
|
|
32
|
+
- `action.yml`: GitHub Action compuesta, lista para usar.
|
|
33
|
+
- `docs/integracion-claude-code.md`: cómo el flujo real (Claude Code más un revisor de otra familia) emite el artefacto al cierre de cada evento.
|
|
34
|
+
|
|
35
|
+
## Uso rápido
|
|
36
|
+
|
|
37
|
+
El paquete se instala una vez (global); cada repositorio se inicializa una vez:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install disensor # o pipx install disensor, recomendado para CLIs
|
|
41
|
+
|
|
42
|
+
disensor init # en la raíz del repo: config, sección de CLAUDE.md y workflow de CI
|
|
43
|
+
disensor new --gate diff --level B # plantilla prellenada en .residue/
|
|
44
|
+
disensor validate .residue/<id>.json # schema + reglas R0 a R10
|
|
45
|
+
disensor gate --no-comment # lo que va a correr CI, en local
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Los subcomandos y flags de la v0.1 en español (`nuevo`, `validar`, `--compuerta`, `--nivel`, `--directorio`, `--sin-comentario`) siguen funcionando como alias.
|
|
49
|
+
|
|
50
|
+
`disensor init` escribe, en forma idempotente, el `disensor.config.json` (el nivel viaja con el código, en un archivo versionado), la sección de cierre de evento en `CLAUDE.md` y el workflow del gate; lo que ya existe se respeta y se informa. Config resultante:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"criticality_level": "B",
|
|
55
|
+
"level_A_enabled": false
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Y el workflow (ver `docs/ejemplo-workflow.yml`):
|
|
60
|
+
|
|
61
|
+
```yaml
|
|
62
|
+
on: pull_request
|
|
63
|
+
permissions:
|
|
64
|
+
contents: read
|
|
65
|
+
pull-requests: write
|
|
66
|
+
jobs:
|
|
67
|
+
gate:
|
|
68
|
+
runs-on: ubuntu-latest
|
|
69
|
+
steps:
|
|
70
|
+
- uses: actions/checkout@v4
|
|
71
|
+
with:
|
|
72
|
+
fetch-depth: 0
|
|
73
|
+
- uses: NicolasRocchia/disensor@v0.2.0
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
El gate valida todos los artefactos de `.residue/` del PR, aplica la política y publica la declaración como comentario (se actualiza en el lugar en cada push).
|
|
77
|
+
|
|
78
|
+
## Qué hace cumplir el gate
|
|
79
|
+
|
|
80
|
+
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 (en inglés y en español), y perfil minimizado sin fugas de texto.
|
|
81
|
+
|
|
82
|
+
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.
|
|
83
|
+
|
|
84
|
+
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.
|
|
85
|
+
|
|
86
|
+
## Qué no hace
|
|
87
|
+
|
|
88
|
+
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 `minimized` del artefacto permite ambientes donde ni siquiera el texto de los hallazgos puede salir del entorno.
|
|
89
|
+
|
|
90
|
+
## Conformidad entre implementaciones
|
|
91
|
+
|
|
92
|
+
`spec/vectors/` contiene los vectores de conformidad: 22 artefactos con su veredicto esperado (válido o no, y las etiquetas de regla que deben dispararse). Toda implementación del validador tiene que pasarlos idénticos: la referencia en Python los corre en la suite (`tests/test_vectors.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.vectors spec/vectors`.
|
|
93
|
+
|
|
94
|
+
`plano-evidencia/` contiene el Worker de ingesta (Cloudflare Workers más D1) con el port TypeScript del validador y el recibo de integridad de solo agregado. Ver su README para el estado de verificación y el despliegue.
|
|
95
|
+
|
|
96
|
+
## Glosario ES-EN
|
|
97
|
+
|
|
98
|
+
La terminología del paper es en español; el contrato (claves y enums del esquema, CLI) es en inglés desde v0.2. Equivalencias principales:
|
|
99
|
+
|
|
100
|
+
| Paper (ES) | Esquema/CLI (EN) |
|
|
101
|
+
|---|---|
|
|
102
|
+
| residuo | residue |
|
|
103
|
+
| hallazgo | finding |
|
|
104
|
+
| compuerta (plan, diff, arquitectura) | gate (plan, diff, architecture) |
|
|
105
|
+
| nivel de criticidad | criticality_level |
|
|
106
|
+
| perfil completo / minimizado | profile full / minimized |
|
|
107
|
+
| actores: generador, revisores, árbitro humano | actors: generator, reviewers, human_arbiter |
|
|
108
|
+
| familia (de modelo) | family |
|
|
109
|
+
| confinamiento (permisos, sandbox, solo lectura por instrucción) | confinement (permissions, sandbox, read_only_by_instruction) |
|
|
110
|
+
| consigna (hash de la consigna adversarial) | prompt_hash |
|
|
111
|
+
| estado final: incorporado, deuda registrada, decisión del dueño, refutado verificable, refutado interpretativo, escalado abierto | final_state: incorporated, debt_recorded, owner_decision, refuted_verifiable, refuted_interpretive, escalated_open |
|
|
112
|
+
| clases de residuo: escalado sin decisión, refutación del principal, gap de ejecución | residue classes: escalation_without_decision, principal_refutation, execution_gap |
|
|
113
|
+
| ruta abreviada / casos protegidos | abbreviated_path / protected_cases_touched |
|
|
114
|
+
| verificación de la corrección | fix_verification |
|
|
115
|
+
| aceptación de referente | lead_acceptance |
|
|
116
|
+
| ausencia declarada / declaración | declared_absence / declaration |
|
|
117
|
+
| métricas: conteos, válidos, falsos positivos | metrics: counts, valid, false_positives |
|
|
118
|
+
|
|
119
|
+
Migración desde v0.1: renombrar `.residuo/` a `.residue/`, las claves del config (`nivel_criticidad` a `criticality_level`, `nivel_A_habilitado` a `level_A_enabled`) y las claves de los artefactos según el glosario. El validador reconoce artefactos v0.1 y lo dice explícitamente; el gate rechaza en voz alta un config con claves viejas en lugar de aplicar defaults en silencio.
|
|
120
|
+
|
|
121
|
+
## Estado
|
|
122
|
+
|
|
123
|
+
v0.2, borrador en uso. Decisión cerrada en v0.2: claves del esquema y CLI en inglés (el español queda como alias en la CLI y como idioma de la documentación). El esquema puede cambiar hasta v1.0; los cambios se declaran en el propio esquema. Decisión abierta antes de v1.0: licencia definitiva (hoy MIT; Apache-2.0 está en consideración por la concesión de patentes antes del release público).
|
|
124
|
+
|
|
125
|
+
## Licencia
|
|
126
|
+
|
|
127
|
+
MIT.
|
disensor-0.2.0/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# disensor
|
|
2
|
+
|
|
3
|
+
Adversarial plan & code review with a declared residue.
|
|
4
|
+
|
|
5
|
+
**English**: disensor emits, validates and CI-enforces *residue declarations*: a JSON artifact that records how each adversarial review event ended (one model generates, a model from another family attacks, every finding reaches a terminal state) and, above all, what the cycle could NOT close by itself. Install with `pip install disensor`, scaffold a repo with `disensor init`. As of v0.2 the whole contract (schema keys, enums, CLI) is English; the docs below are in Spanish, and the ES-EN glossary at the end maps the paper's terminology to the schema.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
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).
|
|
12
|
+
|
|
13
|
+
## Qué hay acá
|
|
14
|
+
|
|
15
|
+
- `spec/residue.schema.json`: el esquema del artefacto (JSON Schema 2020-12), versión residue/v0.2.
|
|
16
|
+
- `spec/examples/`: tres artefactos de ejemplo, incluido un evento real anonimizado y el perfil minimizado sin texto libre.
|
|
17
|
+
- `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, el scaffolding de artefactos y el de repositorios (`init`).
|
|
18
|
+
- `action.yml`: GitHub Action compuesta, lista para usar.
|
|
19
|
+
- `docs/integracion-claude-code.md`: cómo el flujo real (Claude Code más un revisor de otra familia) emite el artefacto al cierre de cada evento.
|
|
20
|
+
|
|
21
|
+
## Uso rápido
|
|
22
|
+
|
|
23
|
+
El paquete se instala una vez (global); cada repositorio se inicializa una vez:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install disensor # o pipx install disensor, recomendado para CLIs
|
|
27
|
+
|
|
28
|
+
disensor init # en la raíz del repo: config, sección de CLAUDE.md y workflow de CI
|
|
29
|
+
disensor new --gate diff --level B # plantilla prellenada en .residue/
|
|
30
|
+
disensor validate .residue/<id>.json # schema + reglas R0 a R10
|
|
31
|
+
disensor gate --no-comment # lo que va a correr CI, en local
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Los subcomandos y flags de la v0.1 en español (`nuevo`, `validar`, `--compuerta`, `--nivel`, `--directorio`, `--sin-comentario`) siguen funcionando como alias.
|
|
35
|
+
|
|
36
|
+
`disensor init` escribe, en forma idempotente, el `disensor.config.json` (el nivel viaja con el código, en un archivo versionado), la sección de cierre de evento en `CLAUDE.md` y el workflow del gate; lo que ya existe se respeta y se informa. Config resultante:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"criticality_level": "B",
|
|
41
|
+
"level_A_enabled": false
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Y el workflow (ver `docs/ejemplo-workflow.yml`):
|
|
46
|
+
|
|
47
|
+
```yaml
|
|
48
|
+
on: pull_request
|
|
49
|
+
permissions:
|
|
50
|
+
contents: read
|
|
51
|
+
pull-requests: write
|
|
52
|
+
jobs:
|
|
53
|
+
gate:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
with:
|
|
58
|
+
fetch-depth: 0
|
|
59
|
+
- uses: NicolasRocchia/disensor@v0.2.0
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
El gate valida todos los artefactos de `.residue/` del PR, aplica la política y publica la declaración como comentario (se actualiza en el lugar en cada push).
|
|
63
|
+
|
|
64
|
+
## Qué hace cumplir el gate
|
|
65
|
+
|
|
66
|
+
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 (en inglés y en español), y perfil minimizado sin fugas de texto.
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
70
|
+
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.
|
|
71
|
+
|
|
72
|
+
## Qué no hace
|
|
73
|
+
|
|
74
|
+
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 `minimized` del artefacto permite ambientes donde ni siquiera el texto de los hallazgos puede salir del entorno.
|
|
75
|
+
|
|
76
|
+
## Conformidad entre implementaciones
|
|
77
|
+
|
|
78
|
+
`spec/vectors/` contiene los vectores de conformidad: 22 artefactos con su veredicto esperado (válido o no, y las etiquetas de regla que deben dispararse). Toda implementación del validador tiene que pasarlos idénticos: la referencia en Python los corre en la suite (`tests/test_vectors.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.vectors spec/vectors`.
|
|
79
|
+
|
|
80
|
+
`plano-evidencia/` contiene el Worker de ingesta (Cloudflare Workers más D1) con el port TypeScript del validador y el recibo de integridad de solo agregado. Ver su README para el estado de verificación y el despliegue.
|
|
81
|
+
|
|
82
|
+
## Glosario ES-EN
|
|
83
|
+
|
|
84
|
+
La terminología del paper es en español; el contrato (claves y enums del esquema, CLI) es en inglés desde v0.2. Equivalencias principales:
|
|
85
|
+
|
|
86
|
+
| Paper (ES) | Esquema/CLI (EN) |
|
|
87
|
+
|---|---|
|
|
88
|
+
| residuo | residue |
|
|
89
|
+
| hallazgo | finding |
|
|
90
|
+
| compuerta (plan, diff, arquitectura) | gate (plan, diff, architecture) |
|
|
91
|
+
| nivel de criticidad | criticality_level |
|
|
92
|
+
| perfil completo / minimizado | profile full / minimized |
|
|
93
|
+
| actores: generador, revisores, árbitro humano | actors: generator, reviewers, human_arbiter |
|
|
94
|
+
| familia (de modelo) | family |
|
|
95
|
+
| confinamiento (permisos, sandbox, solo lectura por instrucción) | confinement (permissions, sandbox, read_only_by_instruction) |
|
|
96
|
+
| consigna (hash de la consigna adversarial) | prompt_hash |
|
|
97
|
+
| estado final: incorporado, deuda registrada, decisión del dueño, refutado verificable, refutado interpretativo, escalado abierto | final_state: incorporated, debt_recorded, owner_decision, refuted_verifiable, refuted_interpretive, escalated_open |
|
|
98
|
+
| clases de residuo: escalado sin decisión, refutación del principal, gap de ejecución | residue classes: escalation_without_decision, principal_refutation, execution_gap |
|
|
99
|
+
| ruta abreviada / casos protegidos | abbreviated_path / protected_cases_touched |
|
|
100
|
+
| verificación de la corrección | fix_verification |
|
|
101
|
+
| aceptación de referente | lead_acceptance |
|
|
102
|
+
| ausencia declarada / declaración | declared_absence / declaration |
|
|
103
|
+
| métricas: conteos, válidos, falsos positivos | metrics: counts, valid, false_positives |
|
|
104
|
+
|
|
105
|
+
Migración desde v0.1: renombrar `.residuo/` a `.residue/`, las claves del config (`nivel_criticidad` a `criticality_level`, `nivel_A_habilitado` a `level_A_enabled`) y las claves de los artefactos según el glosario. El validador reconoce artefactos v0.1 y lo dice explícitamente; el gate rechaza en voz alta un config con claves viejas en lugar de aplicar defaults en silencio.
|
|
106
|
+
|
|
107
|
+
## Estado
|
|
108
|
+
|
|
109
|
+
v0.2, borrador en uso. Decisión cerrada en v0.2: claves del esquema y CLI en inglés (el español queda como alias en la CLI y como idioma de la documentación). El esquema puede cambiar hasta v1.0; los cambios se declaran en el propio esquema. Decisión abierta antes de v1.0: licencia definitiva (hoy MIT; Apache-2.0 está en consideración por la concesión de patentes antes del release público).
|
|
110
|
+
|
|
111
|
+
## Licencia
|
|
112
|
+
|
|
113
|
+
MIT.
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "disensor"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Adversarial plan & code review with a declared residue. Emits, validates and CI-enforces residue declarations (residue/v0.2 schema)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { text = "MIT" }
|
|
@@ -22,4 +22,4 @@ disensor = "disensor.cli:main"
|
|
|
22
22
|
where = ["src"]
|
|
23
23
|
|
|
24
24
|
[tool.setuptools.package-data]
|
|
25
|
-
disensor = ["
|
|
25
|
+
disensor = ["residue.schema.json"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Command line interface: disensor {init, new, validate, gate}.
|
|
2
|
+
|
|
3
|
+
v0.1 was published with Spanish subcommands and flags; they remain as
|
|
4
|
+
aliases (nuevo, validar, and the Spanish long flags) so existing scripts
|
|
5
|
+
and muscle memory keep working.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .gate import main_gate
|
|
15
|
+
from .init import main_init
|
|
16
|
+
from .rules import load_schema, validate_artifact
|
|
17
|
+
from .template import main_new
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main_validate(args) -> int:
|
|
21
|
+
schema = load_schema()
|
|
22
|
+
failed = False
|
|
23
|
+
for path in args.files:
|
|
24
|
+
with open(path, encoding="utf-8") as f:
|
|
25
|
+
artifact = json.load(f)
|
|
26
|
+
errors = validate_artifact(artifact, schema)
|
|
27
|
+
print(f"{path}: {'VALID' if not errors else 'INVALID'}")
|
|
28
|
+
for msg in errors:
|
|
29
|
+
print(f" {msg}")
|
|
30
|
+
failed = failed or bool(errors)
|
|
31
|
+
return 1 if failed else 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
35
|
+
p = argparse.ArgumentParser(
|
|
36
|
+
prog="disensor",
|
|
37
|
+
description="Residue declaration of adversarial review (controlled disagreement).",
|
|
38
|
+
)
|
|
39
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
40
|
+
|
|
41
|
+
init = sub.add_parser("init", help="Scaffold a repository: config, CLAUDE.md section and CI workflow.")
|
|
42
|
+
init.add_argument("--level", "--nivel", choices=["A", "B", "C"], default="B")
|
|
43
|
+
init.add_argument("--no-claude", action="store_true", help="Do not touch CLAUDE.md.")
|
|
44
|
+
init.add_argument("--claude-global", action="store_true",
|
|
45
|
+
help="Write the Claude Code section to ~/.claude/CLAUDE.md instead of the repo.")
|
|
46
|
+
init.add_argument("--no-workflow", action="store_true", help="Do not write the CI workflow.")
|
|
47
|
+
init.set_defaults(func=main_init)
|
|
48
|
+
|
|
49
|
+
new = sub.add_parser("new", aliases=["nuevo"],
|
|
50
|
+
help="Create an artifact template for the current event.")
|
|
51
|
+
new.add_argument("--directory", "--directorio", default=".residue")
|
|
52
|
+
new.add_argument("--gate", "--compuerta", choices=["plan", "diff", "architecture"], default="diff")
|
|
53
|
+
new.add_argument("--level", "--nivel", choices=["A", "B", "C"], default="B")
|
|
54
|
+
new.add_argument("--profile", "--perfil", choices=["full", "minimized"], default="full")
|
|
55
|
+
new.set_defaults(func=main_new)
|
|
56
|
+
|
|
57
|
+
validate = sub.add_parser("validate", aliases=["validar"],
|
|
58
|
+
help="Validate artifacts against the schema and rules R0 to R10.")
|
|
59
|
+
validate.add_argument("files", nargs="+")
|
|
60
|
+
validate.set_defaults(func=main_validate)
|
|
61
|
+
|
|
62
|
+
gate = sub.add_parser("gate", help="CI gate: validates the whole PR and applies policy G1 to G5.")
|
|
63
|
+
gate.add_argument("--directory", "--directorio", default=".residue")
|
|
64
|
+
gate.add_argument("--config", default="disensor.config.json")
|
|
65
|
+
gate.add_argument("--base", default=None, help="Base SHA of the PR (defaults to the GitHub event).")
|
|
66
|
+
gate.add_argument("--head", "--cabeza", default=None, help="Head SHA of the PR (defaults to the GitHub event).")
|
|
67
|
+
gate.add_argument("--no-comment", "--sin-comentario", action="store_true",
|
|
68
|
+
help="Do not post a comment on the PR.")
|
|
69
|
+
gate.set_defaults(func=main_gate)
|
|
70
|
+
return p
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def main() -> None:
|
|
74
|
+
args = build_parser().parse_args()
|
|
75
|
+
sys.exit(args.func(args))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
main()
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""CI gate: validates the residue declarations of a PR and applies policy.
|
|
2
|
+
|
|
3
|
+
Gate checks (on top of the per-artifact R0 to R10 of rules.py):
|
|
4
|
+
G1: at least one valid artifact in the PR range, unless the configuration
|
|
5
|
+
declares the gate as not required (typical in Level C).
|
|
6
|
+
G2: the artifact level matches the declared level of the repository
|
|
7
|
+
(section 12.2: criticality level declared in a repo file).
|
|
8
|
+
G3: Level A blocked while governance is not validated
|
|
9
|
+
(section 3.1: Level A does not start until section 10 is validated).
|
|
10
|
+
G4: confinement policy per level (section 10: the reviewer only reads,
|
|
11
|
+
and that is guaranteed with permissions, not with the brief).
|
|
12
|
+
G5: the reviewed commit belongs to the PR range.
|
|
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 .render import MARKER, render_comment
|
|
24
|
+
from .rules import load_schema, validate_artifact
|
|
25
|
+
|
|
26
|
+
DEFAULT_CONFIG = {
|
|
27
|
+
"criticality_level": "B",
|
|
28
|
+
"level_A_enabled": False,
|
|
29
|
+
"gate": {
|
|
30
|
+
"required": True,
|
|
31
|
+
"level_A_accepted_confinement": ["permissions", "sandbox"],
|
|
32
|
+
"warn_unverified_confinement": True,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
V01_CONFIG_KEYS = {"nivel_criticidad", "nivel_A_habilitado"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_config(path: Path) -> dict:
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return dict(DEFAULT_CONFIG)
|
|
42
|
+
with path.open(encoding="utf-8") as f:
|
|
43
|
+
config = json.load(f)
|
|
44
|
+
old_keys = V01_CONFIG_KEYS & set(config)
|
|
45
|
+
if old_keys:
|
|
46
|
+
raise SystemExit(
|
|
47
|
+
f"{path}: v0.1 Spanish keys found ({', '.join(sorted(old_keys))}). "
|
|
48
|
+
"disensor 0.2 renamed the configuration to English: nivel_criticidad -> "
|
|
49
|
+
"criticality_level, nivel_A_habilitado -> level_A_enabled, gate.requerido -> "
|
|
50
|
+
"gate.required, gate.confinamiento_aceptado_nivel_A -> gate.level_A_accepted_confinement, "
|
|
51
|
+
"gate.advertir_confinamiento_no_verificado -> gate.warn_unverified_confinement. "
|
|
52
|
+
"Failing loudly instead of silently applying defaults."
|
|
53
|
+
)
|
|
54
|
+
combined = dict(DEFAULT_CONFIG)
|
|
55
|
+
combined.update({k: v for k, v in config.items() if k != "gate"})
|
|
56
|
+
gate = dict(DEFAULT_CONFIG["gate"])
|
|
57
|
+
gate.update(config.get("gate", {}))
|
|
58
|
+
combined["gate"] = gate
|
|
59
|
+
return combined
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pr_range(base: str | None, head: str | None) -> tuple[str | None, str | None]:
|
|
63
|
+
"""Base and head of the PR: flags first, then the GitHub event."""
|
|
64
|
+
if base and head:
|
|
65
|
+
return base, head
|
|
66
|
+
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
|
67
|
+
if event_path and Path(event_path).exists():
|
|
68
|
+
with open(event_path, encoding="utf-8") as f:
|
|
69
|
+
event = json.load(f)
|
|
70
|
+
pr = event.get("pull_request") or {}
|
|
71
|
+
return (
|
|
72
|
+
base or (pr.get("base") or {}).get("sha"),
|
|
73
|
+
head or (pr.get("head") or {}).get("sha"),
|
|
74
|
+
)
|
|
75
|
+
return base, head
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def range_commits(base: str, head: str, cwd: Path) -> list[str] | None:
|
|
79
|
+
"""Commits of the base..head range, head included.
|
|
80
|
+
|
|
81
|
+
Returns None if git cannot resolve the range (shallow clone, unknown
|
|
82
|
+
shas): that is a warning. An empty list is a valid result and means the
|
|
83
|
+
range contains no commits: G5 must still be evaluated, because an empty
|
|
84
|
+
range cannot contain the reviewed commit.
|
|
85
|
+
"""
|
|
86
|
+
r = subprocess.run(
|
|
87
|
+
["git", "rev-list", f"{base}..{head}"],
|
|
88
|
+
capture_output=True, text=True, cwd=cwd, check=False,
|
|
89
|
+
)
|
|
90
|
+
if r.returncode != 0:
|
|
91
|
+
return None
|
|
92
|
+
return [line.strip() for line in r.stdout.splitlines() if line.strip()]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def pr_number() -> int | None:
|
|
96
|
+
event_path = os.environ.get("GITHUB_EVENT_PATH")
|
|
97
|
+
if event_path and Path(event_path).exists():
|
|
98
|
+
with open(event_path, encoding="utf-8") as f:
|
|
99
|
+
event = json.load(f)
|
|
100
|
+
pr = event.get("pull_request") or {}
|
|
101
|
+
return pr.get("number")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def post_comment(body: str) -> str:
|
|
106
|
+
"""Creates or updates the gate comment on the PR. Returns a readable status."""
|
|
107
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
108
|
+
repo = os.environ.get("GITHUB_REPOSITORY")
|
|
109
|
+
pr = pr_number()
|
|
110
|
+
if not (token and repo and pr):
|
|
111
|
+
return "no token, repository or PR number: comment not posted"
|
|
112
|
+
api = os.environ.get("GITHUB_API_URL", "https://api.github.com")
|
|
113
|
+
headers = {
|
|
114
|
+
"Authorization": f"Bearer {token}",
|
|
115
|
+
"Accept": "application/vnd.github+json",
|
|
116
|
+
"User-Agent": "disensor-gate",
|
|
117
|
+
}
|
|
118
|
+
try:
|
|
119
|
+
req = urllib.request.Request(
|
|
120
|
+
f"{api}/repos/{repo}/issues/{pr}/comments?per_page=100", headers=headers
|
|
121
|
+
)
|
|
122
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
123
|
+
comments = json.load(resp)
|
|
124
|
+
existing = next((c for c in comments if MARKER in (c.get("body") or "")), None)
|
|
125
|
+
data = json.dumps({"body": body}).encode("utf-8")
|
|
126
|
+
if existing:
|
|
127
|
+
req = urllib.request.Request(
|
|
128
|
+
f"{api}/repos/{repo}/issues/comments/{existing['id']}",
|
|
129
|
+
data=data, headers=headers, method="PATCH",
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
req = urllib.request.Request(
|
|
133
|
+
f"{api}/repos/{repo}/issues/{pr}/comments",
|
|
134
|
+
data=data, headers=headers, method="POST",
|
|
135
|
+
)
|
|
136
|
+
with urllib.request.urlopen(req, timeout=30):
|
|
137
|
+
pass
|
|
138
|
+
return "comment updated" if existing else "comment posted"
|
|
139
|
+
except Exception as exc: # the gate must not crash on network issues
|
|
140
|
+
return f"could not post the comment: {exc}"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def write_summary(body: str) -> None:
|
|
144
|
+
path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
145
|
+
if path:
|
|
146
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
147
|
+
f.write(body + "\n")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run_gate(directory: Path, config_path: Path, base: str | None,
|
|
151
|
+
head: str | None, repo_dir: Path, post: bool = True) -> int:
|
|
152
|
+
config = load_config(config_path)
|
|
153
|
+
schema = load_schema()
|
|
154
|
+
gate_cfg = config["gate"]
|
|
155
|
+
|
|
156
|
+
gate_errors: list[str] = []
|
|
157
|
+
warnings: list[str] = []
|
|
158
|
+
errors_by_file: dict[str, list[str]] = {}
|
|
159
|
+
valid: list[dict] = []
|
|
160
|
+
|
|
161
|
+
base, head = pr_range(base, head)
|
|
162
|
+
commits = range_commits(base, head, repo_dir) if (base and head) else None
|
|
163
|
+
if commits is None and base and head:
|
|
164
|
+
warnings.append(
|
|
165
|
+
"could not resolve the commit range (checkout with fetch-depth 0): G5 not evaluated"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
files = sorted(p for p in directory.glob("*.json")) if directory.exists() else []
|
|
169
|
+
for path in files:
|
|
170
|
+
with path.open(encoding="utf-8") as f:
|
|
171
|
+
try:
|
|
172
|
+
artifact = json.load(f)
|
|
173
|
+
except json.JSONDecodeError as exc:
|
|
174
|
+
errors_by_file[path.name] = [f"invalid JSON: {exc}"]
|
|
175
|
+
continue
|
|
176
|
+
errors = validate_artifact(artifact, schema)
|
|
177
|
+
if errors:
|
|
178
|
+
errors_by_file[path.name] = errors
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
ev = artifact["event"]
|
|
182
|
+
own: list[str] = []
|
|
183
|
+
|
|
184
|
+
# G2: artifact level against the declared level of the repository.
|
|
185
|
+
if ev["criticality_level"] != config["criticality_level"]:
|
|
186
|
+
own.append(
|
|
187
|
+
f"[G2] artifact level ({ev['criticality_level']}) differs from the one declared "
|
|
188
|
+
f"in the repository ({config['criticality_level']})"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# G3: Level A blocked until governance is validated (section 3.1).
|
|
192
|
+
if ev["criticality_level"] == "A" and not config.get("level_A_enabled", False):
|
|
193
|
+
own.append(
|
|
194
|
+
"[G3] Level A blocked: data governance (section 10) is not validated "
|
|
195
|
+
"in this repository (level_A_enabled=false). Applying the protocol at Level A "
|
|
196
|
+
"in this situation is not half-compliance: it is a violation."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# G4: confinement policy per level.
|
|
200
|
+
for r in artifact["actors"]["reviewers"]:
|
|
201
|
+
conf = r["confinement"]
|
|
202
|
+
if ev["criticality_level"] == "A" and conf["mode"] not in gate_cfg["level_A_accepted_confinement"]:
|
|
203
|
+
own.append(
|
|
204
|
+
f"[G4] reviewer {r['reviewer_id']}: confinement '{conf['mode']}' not admitted in Level A "
|
|
205
|
+
f"(the reviewer only reads, and that is guaranteed with permissions, not with the brief)"
|
|
206
|
+
)
|
|
207
|
+
if not conf["verified"] and gate_cfg.get("warn_unverified_confinement", True):
|
|
208
|
+
warnings.append(
|
|
209
|
+
f"{path.name}: confinement of reviewer {r['reviewer_id']} without post-run verification"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# G5: the reviewed commit belongs to the PR range.
|
|
213
|
+
if commits is not None:
|
|
214
|
+
hc = ev["head_commit"]
|
|
215
|
+
if not any(c.startswith(hc) or hc.startswith(c) for c in commits):
|
|
216
|
+
own.append(
|
|
217
|
+
f"[G5] reviewed commit {hc} outside the PR range ({base[:7]}..{head[:7]})"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if own:
|
|
221
|
+
errors_by_file[path.name] = own
|
|
222
|
+
else:
|
|
223
|
+
valid.append(artifact)
|
|
224
|
+
|
|
225
|
+
# G1: at least one valid artifact, unless the gate is not required.
|
|
226
|
+
if gate_cfg.get("required", True) and not valid:
|
|
227
|
+
gate_errors.append(
|
|
228
|
+
"[G1] no valid residue artifact for this PR "
|
|
229
|
+
f"(directory '{directory}'). The declaration lists concrete residue or says "
|
|
230
|
+
"explicitly that there was none; an empty field is not a declaration."
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
body = render_comment(valid, errors_by_file, gate_errors)
|
|
234
|
+
if warnings:
|
|
235
|
+
body += "\n\nWarnings:\n" + "\n".join(f"- {w}" for w in warnings)
|
|
236
|
+
|
|
237
|
+
write_summary(body)
|
|
238
|
+
if post:
|
|
239
|
+
status = post_comment(body)
|
|
240
|
+
print(f"[gate] {status}")
|
|
241
|
+
|
|
242
|
+
failed = bool(gate_errors or errors_by_file)
|
|
243
|
+
print(body)
|
|
244
|
+
print(f"\n[gate] {'FAILED' if failed else 'OK'}: {len(valid)} valid artifact(s), "
|
|
245
|
+
f"{len(errors_by_file)} with errors, {len(gate_errors)} global error(s)")
|
|
246
|
+
return 1 if failed else 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main_gate(args) -> int:
|
|
250
|
+
return run_gate(
|
|
251
|
+
directory=Path(args.directory),
|
|
252
|
+
config_path=Path(args.config),
|
|
253
|
+
base=args.base,
|
|
254
|
+
head=args.head,
|
|
255
|
+
repo_dir=Path.cwd(),
|
|
256
|
+
post=not args.no_comment,
|
|
257
|
+
)
|