@imolko/ultra-agent 0.1.2-beta → 1.0.0-beta
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/bin/cli.js +0 -0
- package/dist-node/materializer.d.ts.map +1 -1
- package/dist-node/materializer.js +2 -1
- package/dist-node/materializer.js.map +1 -1
- package/dist-node/payload.d.ts +18 -4
- package/dist-node/payload.d.ts.map +1 -1
- package/dist-node/payload.js +27 -6
- package/dist-node/payload.js.map +1 -1
- package/dist-node/ship/ship.tracker.d.ts.map +1 -1
- package/dist-node/ship/ship.tracker.js +12 -11
- package/dist-node/ship/ship.tracker.js.map +1 -1
- package/docs/adr/ADR-0000-proceso-y-formato-de-adrs.md +2 -2
- package/docs/adr/foundational/A-042-formato-documentos-issue-tracker.md +1 -0
- package/docs/adr/foundational/A-052-distribucion-metodologia-paquete-versionado.md +1 -1
- package/eslint/index.mjs +67 -7
- package/package.json +13 -4
- package/pi/extensions/qa-guardrail/README.md +6 -2
- package/pi/extensions/qa-guardrail/selection.ts +41 -7
- package/scripts/adr-gate/links.mjs +59 -0
- package/scripts/adr-gate/registry.mjs +155 -0
- package/scripts/adr-gate/report.mjs +21 -0
- package/scripts/adr-gate/rules/enlaces.mjs +33 -0
- package/scripts/adr-gate/rules/frontmatter.mjs +126 -0
- package/scripts/adr-gate/rules/hardcoded.mjs +63 -0
- package/scripts/adr-gate/rules/indices.mjs +100 -0
- package/scripts/adr-gate/rules/jerarquia.mjs +102 -0
- package/scripts/adr-gate/text.mjs +263 -0
- package/scripts/ensure-bin-exec.mjs +21 -0
- package/scripts/verify-adr-consistency.mjs +71 -0
- package/skills/writing-for-agents/references/adrs.md +2 -2
- package/scripts/verify-adr-consistency.py +0 -250
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibilidad de texto y rutas con el gate Python histórico.
|
|
3
|
+
*
|
|
4
|
+
* La salida del gate es un contrato (A-011, T-017): estas funciones replican
|
|
5
|
+
* `str.strip()`, `str.splitlines()`, `sorted()` por code point y
|
|
6
|
+
* `Path.resolve()` de Python. Cualquier cambio aquí cambia los golden files.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { lstatSync, readFileSync, readlinkSync } from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
/** Caracteres que `str.strip()` considera espacio en Python (incluye \x1c-\x1f y \x85). */
|
|
13
|
+
const PY_WHITESPACE_CLASS =
|
|
14
|
+
"[\\t\\n\\v\\f\\r\\x1c-\\x1f \\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000]";
|
|
15
|
+
const PY_STRIP_RE = new RegExp(
|
|
16
|
+
`^${PY_WHITESPACE_CLASS}+|${PY_WHITESPACE_CLASS}+$`,
|
|
17
|
+
"gu",
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
/** Separadores de `str.splitlines()` en Python. */
|
|
21
|
+
const PY_LINE_BREAK_RE = /\r\n|[\r\n\v\f\x1c\x1d\x1e\x85\u2028\u2029]/g;
|
|
22
|
+
|
|
23
|
+
/** Inicio de línea con la semántica de `re.MULTILINE`: solo después de `\n`. */
|
|
24
|
+
const PY_LINE_START = "(?:^|(?<=\\n))";
|
|
25
|
+
|
|
26
|
+
/** Mayor code point del plano básico; por encima, un code point ocupa dos unidades UTF-16. */
|
|
27
|
+
const MAX_BMP_CODE_POINT = 0xffff;
|
|
28
|
+
|
|
29
|
+
export const H1_RE = /^# (A|T|P)-\p{Nd}{3}: .+/u;
|
|
30
|
+
|
|
31
|
+
export const ORIGINALLY_RE = new RegExp(`${PY_LINE_START}\\*\\*originally\\*\\*:`);
|
|
32
|
+
export const ORIGINALLY_WRONG_RE = new RegExp(
|
|
33
|
+
`${PY_LINE_START}\\*\\*[Oo]riginally\\*\\*:`,
|
|
34
|
+
);
|
|
35
|
+
export const ENRICHED_RE = new RegExp(
|
|
36
|
+
`${PY_LINE_START}\\*\\*Enriquecido desde\\*\\*:`,
|
|
37
|
+
);
|
|
38
|
+
export const ENRICHED_WRONG_RE = new RegExp(
|
|
39
|
+
`${PY_LINE_START}\\*\\*[Ee]nriquecido [Dd]esde\\*\\*:`,
|
|
40
|
+
);
|
|
41
|
+
export const ESTADO_RE = new RegExp(
|
|
42
|
+
`${PY_LINE_START}\\*\\*Estado\\*\\*: ?([^\\n]+)(?=\\n|$)`,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Citas en texto plano a un ID de ADR, con `\b` y `\d` Unicode como en Python. */
|
|
46
|
+
export const PLAIN_ID_RE =
|
|
47
|
+
/(?<![\p{L}\p{N}_])([A-Z]{1,2})-\p{Nd}{3}(?![\p{L}\p{N}_])/gu;
|
|
48
|
+
|
|
49
|
+
/** Destino de un enlace markdown inline `](destino)` para la regla `enlaces`. */
|
|
50
|
+
export const LINK_DEST_RE = /\]\(([^)]+)\)/gu;
|
|
51
|
+
|
|
52
|
+
/** Destino de un enlace markdown inline que se elimina antes del escaneo de
|
|
53
|
+
* texto plano de la regla `jerarquia`. */
|
|
54
|
+
export const LINK_TARGET_RE = /\]\([^)]*\)/gu;
|
|
55
|
+
|
|
56
|
+
/** Enlace markdown inline completo: etiqueta y destino. */
|
|
57
|
+
export const LINK_RE = /\[([^\]]*)\]\(([^)]*)\)/gu;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Recorta espacios con la misma semántica que `str.strip()` de Python.
|
|
61
|
+
* @param {string} value
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function pyStrip(value) {
|
|
65
|
+
return value.replace(PY_STRIP_RE, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Divide un texto por los mismos separadores que `str.splitlines()` de Python,
|
|
70
|
+
* sin conservar los saltos y sin añadir una línea vacía final.
|
|
71
|
+
* @param {string} text
|
|
72
|
+
* @returns {string[]}
|
|
73
|
+
*/
|
|
74
|
+
export function splitLines(text) {
|
|
75
|
+
if (text === "") {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const lines = [];
|
|
80
|
+
let start = 0;
|
|
81
|
+
for (const match of text.matchAll(PY_LINE_BREAK_RE)) {
|
|
82
|
+
lines.push(text.slice(start, match.index));
|
|
83
|
+
start = match.index + match[0].length;
|
|
84
|
+
}
|
|
85
|
+
if (start < text.length) {
|
|
86
|
+
lines.push(text.slice(start));
|
|
87
|
+
}
|
|
88
|
+
return lines;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Compara dos cadenas por code point Unicode, como `sorted()` de Python.
|
|
93
|
+
* @param {string} left
|
|
94
|
+
* @param {string} right
|
|
95
|
+
* @returns {number}
|
|
96
|
+
*/
|
|
97
|
+
export function compareCodePoints(left, right) {
|
|
98
|
+
let leftIndex = 0;
|
|
99
|
+
let rightIndex = 0;
|
|
100
|
+
|
|
101
|
+
while (leftIndex < left.length && rightIndex < right.length) {
|
|
102
|
+
const leftPoint = left.codePointAt(leftIndex);
|
|
103
|
+
const rightPoint = right.codePointAt(rightIndex);
|
|
104
|
+
if (leftPoint !== rightPoint) {
|
|
105
|
+
return leftPoint - rightPoint;
|
|
106
|
+
}
|
|
107
|
+
leftIndex += leftPoint > MAX_BMP_CODE_POINT ? 2 : 1;
|
|
108
|
+
rightIndex += rightPoint > MAX_BMP_CODE_POINT ? 2 : 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return left.length - leftIndex - (right.length - rightIndex);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Ordena cadenas por code point sin depender del orden de `readdir`.
|
|
116
|
+
* @param {string[]} entries
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
export function sortByCodePoint(entries) {
|
|
120
|
+
return [...entries].sort(compareCodePoints);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Descompone una ruta en los componentes que compara `pathlib.Path`: la raíz
|
|
125
|
+
* cuenta como primer componente y los separadores repetidos no crean vacíos.
|
|
126
|
+
* @param {string} value
|
|
127
|
+
* @returns {string[]}
|
|
128
|
+
*/
|
|
129
|
+
function pathParts(value) {
|
|
130
|
+
const root = path.parse(value).root;
|
|
131
|
+
const rest = value.slice(root.length).split(path.sep).filter(Boolean);
|
|
132
|
+
return root === "" ? rest : [root, ...rest];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Compara dos rutas componente a componente, como `sorted(Path(...))` de Python.
|
|
137
|
+
* @param {string} left
|
|
138
|
+
* @param {string} right
|
|
139
|
+
* @returns {number}
|
|
140
|
+
*/
|
|
141
|
+
export function comparePathComponents(left, right) {
|
|
142
|
+
const leftParts = pathParts(left);
|
|
143
|
+
const rightParts = pathParts(right);
|
|
144
|
+
const limit = Math.min(leftParts.length, rightParts.length);
|
|
145
|
+
|
|
146
|
+
for (let index = 0; index < limit; index += 1) {
|
|
147
|
+
const result = compareCodePoints(leftParts[index], rightParts[index]);
|
|
148
|
+
if (result !== 0) {
|
|
149
|
+
return result;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return leftParts.length - rightParts.length;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Resuelve una ruta como `Path.resolve()` de Python: sigue symlinks componente a
|
|
158
|
+
* componente y, si un componente no existe, conserva el resto sin resolver.
|
|
159
|
+
* @param {string} input
|
|
160
|
+
* @returns {string}
|
|
161
|
+
*/
|
|
162
|
+
export function resolvePath(input) {
|
|
163
|
+
const absolute = path.isAbsolute(input) ? input : path.resolve(input);
|
|
164
|
+
const root = path.parse(absolute).root;
|
|
165
|
+
const state = {
|
|
166
|
+
pending: absolute.slice(root.length).split(path.sep),
|
|
167
|
+
resolved: root,
|
|
168
|
+
seen: new Set(),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
while (state.pending.length > 0) {
|
|
172
|
+
resolveNext(state);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return state.resolved;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resuelve el siguiente componente pendiente, siguiendo un symlink si lo hay.
|
|
180
|
+
* @param {{ pending: string[], resolved: string, seen: Set<string> }} state
|
|
181
|
+
* @returns {void}
|
|
182
|
+
*/
|
|
183
|
+
function resolveNext(state) {
|
|
184
|
+
const name = state.pending.shift();
|
|
185
|
+
if (!name || name === ".") {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (name === "..") {
|
|
189
|
+
state.resolved = path.dirname(state.resolved);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const candidate = path.join(state.resolved, name);
|
|
194
|
+
const stats = lstatOrNull(candidate);
|
|
195
|
+
if (!stats || !stats.isSymbolicLink()) {
|
|
196
|
+
state.resolved = candidate;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
followSymlink(state, candidate);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Lee `lstat` sin lanzar: un componente inexistente se conserva como texto.
|
|
205
|
+
* @param {string} candidate
|
|
206
|
+
* @returns {import("node:fs").Stats | null}
|
|
207
|
+
*/
|
|
208
|
+
function lstatOrNull(candidate) {
|
|
209
|
+
try {
|
|
210
|
+
return lstatSync(candidate);
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Sustituye el symlink pendiente por su destino; un ciclo conserva la parte
|
|
218
|
+
* resuelta y deja el resto sin resolver, como `Path.resolve()`.
|
|
219
|
+
* @param {{ pending: string[], resolved: string, seen: Set<string> }} state
|
|
220
|
+
* @param {string} candidate
|
|
221
|
+
* @returns {void}
|
|
222
|
+
*/
|
|
223
|
+
function followSymlink(state, candidate) {
|
|
224
|
+
if (state.seen.has(candidate)) {
|
|
225
|
+
state.resolved = path.resolve(candidate, ...state.pending);
|
|
226
|
+
state.pending = [];
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
state.seen.add(candidate);
|
|
230
|
+
|
|
231
|
+
const linkTarget = readlinkSync(candidate);
|
|
232
|
+
if (!path.isAbsolute(linkTarget)) {
|
|
233
|
+
state.pending.unshift(...linkTarget.split(path.sep));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const root = path.parse(linkTarget).root;
|
|
238
|
+
state.resolved = root;
|
|
239
|
+
state.pending.unshift(...linkTarget.slice(root.length).split(path.sep));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Junta un directorio y una ruta relativa sin normalizar `.` ni `..`, para que
|
|
244
|
+
* `resolvePath()` los procese con la misma semántica que Python.
|
|
245
|
+
* @param {string} directory
|
|
246
|
+
* @param {string} relative
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
export function joinUnnormalized(directory, relative) {
|
|
250
|
+
return path.isAbsolute(relative)
|
|
251
|
+
? relative
|
|
252
|
+
: `${directory}${path.sep}${relative}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Lee un ADR con la misma traducción de saltos que el modo texto de Python:
|
|
257
|
+
* `\r\n` y `\r` se convierten en `\n` antes de aplicar las reglas.
|
|
258
|
+
* @param {string} file
|
|
259
|
+
* @returns {string}
|
|
260
|
+
*/
|
|
261
|
+
export function readText(file) {
|
|
262
|
+
return readFileSync(file, "utf8").replace(/\r\n?/g, "\n");
|
|
263
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Asegura que el CLI y el gate de ADRs se publiquen con bit de ejecución.
|
|
4
|
+
*
|
|
5
|
+
* El blob de git solo distingue ejecutable / no ejecutable, y `npm pack`
|
|
6
|
+
* conserva ese modo: un binario en 100644 produce un tarball que algunos
|
|
7
|
+
* gestores de paquetes no reparan. Se ejecuta en `prepack` para que el
|
|
8
|
+
* artefacto sea correcto por sí mismo (A-007, A-052 §4).
|
|
9
|
+
*
|
|
10
|
+
* En Windows el modo POSIX no aplica: el script es un no-op.
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync } from "node:fs";
|
|
13
|
+
|
|
14
|
+
const EXECUTABLES = ["bin/cli.js", "scripts/verify-adr-consistency.mjs"];
|
|
15
|
+
const EXECUTABLE_MODE = 0o755;
|
|
16
|
+
|
|
17
|
+
if (process.platform !== "win32") {
|
|
18
|
+
for (const executable of EXECUTABLES) {
|
|
19
|
+
chmodSync(executable, EXECUTABLE_MODE);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gate de consistencia del registro ADR (ADR-0000).
|
|
4
|
+
*
|
|
5
|
+
* Valida cinco reglas y sale con código 0 solo si no hay violaciones:
|
|
6
|
+
* - jerarquia los ADRs no referencian tiers prohibidos (A solo cita A; T cita A y T;
|
|
7
|
+
* P cita A y P). Detecta tanto enlaces markdown como citas en texto plano a
|
|
8
|
+
* IDs `<PREFIJO>-NNN`: una cita con un prefijo no permitido para el tier —o
|
|
9
|
+
* con cualquier otro prefijo, incluidos G y PJ— es una violación, porque
|
|
10
|
+
* ningún ADR A/T/P referencia el tier de proyecto. Una misma referencia
|
|
11
|
+
* prohibida se reporta una sola vez aunque aparezca como enlace y como ID.
|
|
12
|
+
* - enlaces todos los enlaces markdown internos (relativos, *.md) resuelven a un
|
|
13
|
+
* archivo existente.
|
|
14
|
+
* - frontmatter el título H1 sigue `# <TIER>-<NNN>: Título` y los campos `originally` /
|
|
15
|
+
* `Enriquecido desde` usan la ortografía exacta.
|
|
16
|
+
* - indices cada ADR aparece en el índice de su tier y el índice no lista ADRs
|
|
17
|
+
* inexistentes.
|
|
18
|
+
* - hardcoded los ADRs activos no duplican listas cuya fuente canónica vive en el
|
|
19
|
+
* paquete (skills → @imolko/ultra-agent) ni en docs/agents/ (punteros).
|
|
20
|
+
*
|
|
21
|
+
* El gate resuelve su raíz desde su propia ubicación, no desde `process.cwd()`: se ejecuta
|
|
22
|
+
* contra el registro del repositorio que lo contiene. La implementación vive en
|
|
23
|
+
* `scripts/adr-gate/` para respetar las métricas de A-046; este archivo solo orquesta.
|
|
24
|
+
*
|
|
25
|
+
* Uso: node scripts/verify-adr-consistency.mjs
|
|
26
|
+
*/
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
import { createRegistry } from "./adr-gate/registry.mjs";
|
|
31
|
+
import { createReport } from "./adr-gate/report.mjs";
|
|
32
|
+
import { ruleEnlaces } from "./adr-gate/rules/enlaces.mjs";
|
|
33
|
+
import { ruleFrontmatter } from "./adr-gate/rules/frontmatter.mjs";
|
|
34
|
+
import { ruleHardcoded } from "./adr-gate/rules/hardcoded.mjs";
|
|
35
|
+
import { ruleIndices } from "./adr-gate/rules/indices.mjs";
|
|
36
|
+
import { ruleJerarquia } from "./adr-gate/rules/jerarquia.mjs";
|
|
37
|
+
import { resolvePath } from "./adr-gate/text.mjs";
|
|
38
|
+
|
|
39
|
+
const SCRIPT_PATH = fileURLToPath(import.meta.url);
|
|
40
|
+
const ROOT = path.dirname(path.dirname(resolvePath(SCRIPT_PATH)));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Ejecuta las cinco reglas en el orden del gate original y emite el resultado:
|
|
44
|
+
* violaciones por stderr con exit 1, o el resumen limpio por stdout con exit 0.
|
|
45
|
+
* @returns {number} código de salida
|
|
46
|
+
*/
|
|
47
|
+
function main() {
|
|
48
|
+
const registry = createRegistry(ROOT);
|
|
49
|
+
const report = createReport();
|
|
50
|
+
|
|
51
|
+
ruleJerarquia(registry, report);
|
|
52
|
+
ruleEnlaces(registry, report);
|
|
53
|
+
ruleFrontmatter(registry, report);
|
|
54
|
+
ruleIndices(registry, report);
|
|
55
|
+
ruleHardcoded(registry, report);
|
|
56
|
+
|
|
57
|
+
if (report.violations.length > 0) {
|
|
58
|
+
for (const violation of report.violations) {
|
|
59
|
+
process.stderr.write(`${violation}\n`);
|
|
60
|
+
}
|
|
61
|
+
process.stderr.write(
|
|
62
|
+
`${report.violations.length} violación(es) en el registro ADR.\n`,
|
|
63
|
+
);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
process.stdout.write("Registro ADR consistente: 0 violaciones.\n");
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
process.exitCode = main();
|
|
@@ -6,7 +6,7 @@ The format, tier hierarchy, tag vocabulary, and lifecycle are owned by `<adr_dir
|
|
|
6
6
|
|
|
7
7
|
## The levers, instantiated
|
|
8
8
|
|
|
9
|
-
- **Checkable, exhaustive completion criterion.** The gate `scripts/verify-adr-consistency.
|
|
9
|
+
- **Checkable, exhaustive completion criterion.** The gate `scripts/verify-adr-consistency.mjs` must exit `0` with zero violations. "Done" is a binary, machine-checked state — not a vague "understanding reached". This is the completion-criterion lever from `SKILL.md`, applied to a whole document family.
|
|
10
10
|
- **Environment as source of truth (the cache rule).** The gate's `hardcoded` rule *is* the cache principle, enforced by machine.
|
|
11
11
|
- **Single source of truth.** `<adr_dir>/index.md` is the canonical index, enforced by the `indices` gate rule.
|
|
12
12
|
- **Context pointers.** `<adr_dir>/index.md` names each ADR and its tier — a real pointer hub doing both jobs (states what the material is, encodes when to reach it). Each tier's `index.md` is the same object one level down.
|
|
@@ -15,5 +15,5 @@ The format, tier hierarchy, tag vocabulary, and lifecycle are owned by `<adr_dir
|
|
|
15
15
|
## When writing an ADR
|
|
16
16
|
|
|
17
17
|
- Follow `ADR-0000` exactly: Nygard sections, the project frontmatter, the tier matching the decision's scope, the approved tag vocabulary, the numbering and lifecycle rules.
|
|
18
|
-
- Run the gate before finishing: `
|
|
18
|
+
- Run the gate before finishing: `node scripts/verify-adr-consistency.mjs`. Exit `0` is the completion criterion.
|
|
19
19
|
- Link related ADRs by relative markdown path (the `jerarquia` and `enlaces` gate rules check this); point at them, don't restate them.
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Gate de consistencia del registro ADR (ADR-0000).
|
|
3
|
-
|
|
4
|
-
Valida cinco reglas y sale con código 0 solo si no hay violaciones:
|
|
5
|
-
- jerarquia los ADRs no referencian tiers prohibidos (A solo cita A; T cita A y T;
|
|
6
|
-
P cita A y P). Detecta tanto enlaces markdown como citas en texto plano a
|
|
7
|
-
IDs `<PREFIJO>-NNN`: una cita con un prefijo no permitido para el tier —o
|
|
8
|
-
con cualquier otro prefijo, incluidos G y PJ— es una violación, porque
|
|
9
|
-
ningún ADR A/T/P referencia el tier de proyecto. Una misma referencia
|
|
10
|
-
prohibida se reporta una sola vez aunque aparezca como enlace y como ID.
|
|
11
|
-
- enlaces todos los enlaces markdown internos (relativos, *.md) resuelven a un
|
|
12
|
-
archivo existente.
|
|
13
|
-
- frontmatter el título H1 sigue `# <TIER>-<NNN>: Título` y los campos `originally` /
|
|
14
|
-
`Enriquecido desde` usan la ortografía exacta.
|
|
15
|
-
- indices cada ADR aparece en el índice de su tier y el índice no lista ADRs
|
|
16
|
-
inexistentes.
|
|
17
|
-
- hardcoded los ADRs activos no duplican listas cuya fuente canónica vive en el
|
|
18
|
-
paquete (skills → @imolko/ultra-agent) ni en docs/agents/ (punteros).
|
|
19
|
-
|
|
20
|
-
Uso: python3 scripts/verify-adr-consistency.py
|
|
21
|
-
"""
|
|
22
|
-
|
|
23
|
-
import re
|
|
24
|
-
import sys
|
|
25
|
-
from pathlib import Path
|
|
26
|
-
|
|
27
|
-
ROOT = Path(__file__).resolve().parent.parent
|
|
28
|
-
ADR_DIR = ROOT / "docs" / "adr"
|
|
29
|
-
|
|
30
|
-
TIER_DIRS = {
|
|
31
|
-
"A": ADR_DIR / "foundational",
|
|
32
|
-
"T": ADR_DIR / "typescript",
|
|
33
|
-
"P": ADR_DIR / "python",
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
H1_RE = re.compile(r"^# (A|T|P)-\d{3}: .+")
|
|
37
|
-
ORIGINALLY_RE = re.compile(r"^\*\*originally\*\*:", flags=re.MULTILINE)
|
|
38
|
-
ORIGINALLY_WRONG_RE = re.compile(r"^\*\*[Oo]riginally\*\*:", flags=re.MULTILINE)
|
|
39
|
-
ENRICHED_RE = re.compile(r"^\*\*Enriquecido desde\*\*:", flags=re.MULTILINE)
|
|
40
|
-
ENRICHED_WRONG_RE = re.compile(r"^\*\*[Ee]nriquecido [Dd]esde\*\*:", flags=re.MULTILINE)
|
|
41
|
-
|
|
42
|
-
# Anchors caducados: la fuente canónica de los skills pasó al paquete y el hub de
|
|
43
|
-
# punteros pasó a docs/agents/ (A-052). Un ADR activo no debe repetirlos.
|
|
44
|
-
STALE_ANCHORS = (".pi/skills/", ".pi/repo-config.md")
|
|
45
|
-
|
|
46
|
-
violations = []
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def report(rule: str, message: str) -> None:
|
|
50
|
-
violations.append(f"[{rule}] {message}")
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def is_active_adr(text: str) -> bool:
|
|
54
|
-
match = re.search(r"^\*\*Estado\*\*: ?(.+)$", text, flags=re.MULTILINE)
|
|
55
|
-
if not match:
|
|
56
|
-
return True
|
|
57
|
-
state = match.group(1).strip()
|
|
58
|
-
return state not in ("Reemplazado", "Deprecado")
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def tier_adr_files(tier: str):
|
|
62
|
-
return sorted(TIER_DIRS[tier].glob(f"{tier}-*.md"))
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def link_targets(text: str):
|
|
66
|
-
"""Devuelve los destinos de los enlaces markdown inline `](dest)`. """
|
|
67
|
-
return re.findall(r"\]\(([^)]+)\)", text)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def resolve_link(adir_file: Path, target: str):
|
|
71
|
-
"""Resuelve un target de enlace relativo contra el directorio del ADR."""
|
|
72
|
-
target = target.strip()
|
|
73
|
-
if target.startswith(("<", ">")) or "://" in target or target.startswith("#"):
|
|
74
|
-
return None # URL externa, autolink o ancla pura — fuera del gate
|
|
75
|
-
# Ignorar fragmentos (#ancla)
|
|
76
|
-
target = target.split("#", 1)[0]
|
|
77
|
-
if not target:
|
|
78
|
-
return None
|
|
79
|
-
if not target.endswith(".md"):
|
|
80
|
-
return None
|
|
81
|
-
return (adir_file.parent / target).resolve()
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def target_tier(resolved: Path):
|
|
85
|
-
"""Clasifica un archivo resuelto en su tier (o None si está fuera del registro)."""
|
|
86
|
-
try:
|
|
87
|
-
rel = resolved.relative_to(ADR_DIR)
|
|
88
|
-
except ValueError:
|
|
89
|
-
return None
|
|
90
|
-
parts = rel.parts
|
|
91
|
-
if len(parts) >= 2:
|
|
92
|
-
if parts[0] == "foundational":
|
|
93
|
-
return "A"
|
|
94
|
-
if parts[0] == "typescript":
|
|
95
|
-
return "T"
|
|
96
|
-
if parts[0] == "python":
|
|
97
|
-
return "P"
|
|
98
|
-
if rel.name.startswith("ADR-0000") or rel.name == "index.md":
|
|
99
|
-
return "meta"
|
|
100
|
-
return "project"
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
ALLOWED_TARGETS = {
|
|
104
|
-
"A": {"A", "meta"},
|
|
105
|
-
"T": {"A", "T", "meta"},
|
|
106
|
-
"P": {"A", "P", "meta"},
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
# Citas en texto plano a un ID de ADR. Una a dos letras mayúsculas evita falsos
|
|
110
|
-
# positivos con identificadores de tres letras (ISO-8601, RFC-2119, SHA-256).
|
|
111
|
-
PLAIN_ID_RE = re.compile(r"\b([A-Z]{1,2})-\d{3}\b")
|
|
112
|
-
|
|
113
|
-
# Destino de un enlace markdown inline `](destino)`. Se elimina antes del escaneo
|
|
114
|
-
# de texto plano para que una referencia no se reporte también como cita.
|
|
115
|
-
LINK_TARGET_RE = re.compile(r"\]\([^)]*\)")
|
|
116
|
-
|
|
117
|
-
# Enlace markdown inline completo: etiqueta y destino. La etiqueta puede citar el
|
|
118
|
-
# ID cuando el destino no lo contiene (p. ej. `[T-001](../typescript/index.md)`).
|
|
119
|
-
LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]*)\)")
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def rule_jerarquia() -> None:
|
|
123
|
-
for tier, allowed in ALLOWED_TARGETS.items():
|
|
124
|
-
allowed_prefixes = {t for t in allowed if t in TIER_DIRS}
|
|
125
|
-
for adr in tier_adr_files(tier):
|
|
126
|
-
text = adr.read_text(encoding="utf-8")
|
|
127
|
-
reported_ids = set()
|
|
128
|
-
|
|
129
|
-
for label, target in LINK_RE.findall(text):
|
|
130
|
-
resolved = resolve_link(adr, target)
|
|
131
|
-
if resolved is None:
|
|
132
|
-
continue
|
|
133
|
-
resolved_tier = target_tier(resolved)
|
|
134
|
-
if resolved_tier is None or resolved_tier in allowed:
|
|
135
|
-
continue
|
|
136
|
-
# Los IDs citados por el enlace —en la etiqueta o en el destino—
|
|
137
|
-
# quedan cubiertos por esta violación y no se reportan otra vez.
|
|
138
|
-
for match in PLAIN_ID_RE.finditer(f"{label} {target}"):
|
|
139
|
-
reported_ids.add(match.group(0))
|
|
140
|
-
report(
|
|
141
|
-
"jerarquia",
|
|
142
|
-
f"{adr.relative_to(ROOT)} referencia al tier {resolved_tier} ({target}), prohibido para el tier {tier}",
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
plain_text = LINK_TARGET_RE.sub("", text)
|
|
146
|
-
for match in PLAIN_ID_RE.finditer(plain_text):
|
|
147
|
-
prefix, ident = match.group(1), match.group(0)
|
|
148
|
-
if prefix in allowed_prefixes or ident in reported_ids:
|
|
149
|
-
continue
|
|
150
|
-
reported_ids.add(ident)
|
|
151
|
-
report(
|
|
152
|
-
"jerarquia",
|
|
153
|
-
f"{adr.relative_to(ROOT)} cita en texto plano {ident} (prefijo {prefix}), prohibido para el tier {tier}",
|
|
154
|
-
)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
def rule_enlaces() -> None:
|
|
158
|
-
for md in sorted(ADR_DIR.rglob("*.md")):
|
|
159
|
-
text = md.read_text(encoding="utf-8")
|
|
160
|
-
for target in link_targets(text):
|
|
161
|
-
resolved = resolve_link(md, target)
|
|
162
|
-
if resolved is None:
|
|
163
|
-
continue
|
|
164
|
-
if not resolved.exists():
|
|
165
|
-
report("enlaces", f"{md.relative_to(ROOT)} enlaza a un archivo inexistente: {target}")
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def rule_frontmatter() -> None:
|
|
169
|
-
for tier in ("A", "T", "P"):
|
|
170
|
-
for adr in tier_adr_files(tier):
|
|
171
|
-
text = adr.read_text(encoding="utf-8")
|
|
172
|
-
rel = adr.relative_to(ROOT)
|
|
173
|
-
|
|
174
|
-
first_line = text.splitlines()[0] if text.splitlines() else ""
|
|
175
|
-
if not H1_RE.match(first_line):
|
|
176
|
-
report("frontmatter", f"{rel}: el título H1 no sigue `# <TIER>-<NNN>: Título`")
|
|
177
|
-
|
|
178
|
-
if not ORIGINALLY_RE.search(text):
|
|
179
|
-
report("frontmatter", f"{rel}: falta el campo `**originally**:` (minúsculas)")
|
|
180
|
-
|
|
181
|
-
# Detectar ortografía incorrecta del campo
|
|
182
|
-
for line in text.splitlines():
|
|
183
|
-
stripped = line.strip()
|
|
184
|
-
if ORIGINALLY_WRONG_RE.match(stripped) and not ORIGINALLY_RE.match(stripped):
|
|
185
|
-
report("frontmatter", f"{rel}: `originally` debe ir en minúsculas")
|
|
186
|
-
break
|
|
187
|
-
|
|
188
|
-
for line in text.splitlines():
|
|
189
|
-
stripped = line.strip()
|
|
190
|
-
if ENRICHED_WRONG_RE.match(stripped) and not ENRICHED_RE.match(stripped):
|
|
191
|
-
report("frontmatter", f"{rel}: el campo opcional debe escribirse `**Enriquecido desde**:`")
|
|
192
|
-
break
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def rule_indices() -> None:
|
|
196
|
-
for tier in ("A", "T", "P"):
|
|
197
|
-
index = TIER_DIRS[tier] / "index.md"
|
|
198
|
-
if not index.exists():
|
|
199
|
-
report("indices", f"falta el índice del tier {tier}: {index.relative_to(ROOT)}")
|
|
200
|
-
continue
|
|
201
|
-
|
|
202
|
-
index_text = index.read_text(encoding="utf-8")
|
|
203
|
-
# Enlaces dentro del tier (`./<nombre>.md`)
|
|
204
|
-
index_targets = {t for t in link_targets(index_text) if t.startswith("./") and t.endswith(".md")}
|
|
205
|
-
|
|
206
|
-
adr_files = tier_adr_files(tier)
|
|
207
|
-
for adr in adr_files:
|
|
208
|
-
target = f"./{adr.name}"
|
|
209
|
-
if target not in index_targets:
|
|
210
|
-
report("indices", f"{adr.relative_to(ROOT)} no aparece en el índice de su tier")
|
|
211
|
-
|
|
212
|
-
for target in index_targets:
|
|
213
|
-
resolved = (TIER_DIRS[tier] / target[2:]).resolve()
|
|
214
|
-
if not resolved.exists():
|
|
215
|
-
report("indices", f"el índice {index.relative_to(ROOT)} lista un ADR inexistente: {target}")
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def rule_hardcoded() -> None:
|
|
219
|
-
for tier in ("A", "T", "P"):
|
|
220
|
-
for adr in tier_adr_files(tier):
|
|
221
|
-
text = adr.read_text(encoding="utf-8")
|
|
222
|
-
if not is_active_adr(text):
|
|
223
|
-
continue
|
|
224
|
-
for anchor in STALE_ANCHORS:
|
|
225
|
-
if anchor in text:
|
|
226
|
-
report(
|
|
227
|
-
"hardcoded",
|
|
228
|
-
f"{adr.relative_to(ROOT)} duplica el ancla caducada `{anchor}` (fuente canónica: paquete/docs/agents)",
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
def main() -> int:
|
|
233
|
-
rule_jerarquia()
|
|
234
|
-
rule_enlaces()
|
|
235
|
-
rule_frontmatter()
|
|
236
|
-
rule_indices()
|
|
237
|
-
rule_hardcoded()
|
|
238
|
-
|
|
239
|
-
if violations:
|
|
240
|
-
for v in violations:
|
|
241
|
-
print(v, file=sys.stderr)
|
|
242
|
-
print(f"{len(violations)} violación(es) en el registro ADR.", file=sys.stderr)
|
|
243
|
-
return 1
|
|
244
|
-
|
|
245
|
-
print("Registro ADR consistente: 0 violaciones.")
|
|
246
|
-
return 0
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
if __name__ == "__main__":
|
|
250
|
-
sys.exit(main())
|