@imolko/ultra-agent 0.2.0-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.
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Regla `hardcoded`: los ADRs activos no duplican anclas cuya fuente canónica
3
+ * vive en el paquete o en docs/agents/.
4
+ */
5
+
6
+ import path from "node:path";
7
+
8
+ import { tierAdrFiles } from "../registry.mjs";
9
+ import { ESTADO_RE, pyStrip, readText } from "../text.mjs";
10
+
11
+ /** Anchors caducados: la fuente canónica pasó al paquete y a docs/agents/ (A-052). */
12
+ const STALE_ANCHORS = [".pi/skills/", ".pi/repo-config.md"];
13
+
14
+ /**
15
+ * Ejecuta la regla sobre los ADRs activos de los tres tiers.
16
+ * @param {ReturnType<import("../registry.mjs").createRegistry>} registry
17
+ * @param {ReturnType<import("../report.mjs").createReport>} report
18
+ * @returns {void}
19
+ */
20
+ export function ruleHardcoded(registry, report) {
21
+ for (const tier of registry.tiers) {
22
+ for (const adr of tierAdrFiles(tier)) {
23
+ checkHardcoded({ registry, adr, report });
24
+ }
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Comprueba las anclas caducadas de un ADR activo.
30
+ * @param {{ registry: object, adr: string, report: object }} context
31
+ * @returns {void}
32
+ */
33
+ function checkHardcoded(context) {
34
+ const text = readText(context.adr);
35
+ if (!isActiveAdr(text)) {
36
+ return;
37
+ }
38
+
39
+ for (const anchor of STALE_ANCHORS) {
40
+ if (text.includes(anchor)) {
41
+ const rel = path.relative(context.registry.root, context.adr);
42
+ context.report.add(
43
+ "hardcoded",
44
+ `${rel} duplica el ancla caducada \`${anchor}\` (fuente canónica: paquete/docs/agents)`,
45
+ );
46
+ }
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Marca un ADR como activo salvo que su `**Estado**` sea `Reemplazado` o
52
+ * `Deprecado`; la ausencia del campo se trata como activo.
53
+ * @param {string} text
54
+ * @returns {boolean}
55
+ */
56
+ function isActiveAdr(text) {
57
+ const match = ESTADO_RE.exec(text);
58
+ if (!match) {
59
+ return true;
60
+ }
61
+ const state = pyStrip(match[1]);
62
+ return state !== "Reemplazado" && state !== "Deprecado";
63
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Regla `indices`: cada ADR aparece en el índice de su tier y el índice no lista
3
+ * ADRs inexistentes.
4
+ *
5
+ * Los destinos inexistentes se comprueban en orden canónico por code point. El
6
+ * gate histórico iteraba un `set` de Python, cuyo orden dependía de
7
+ * `PYTHONHASHSEED`; fijar el orden hace reproducible el golden (T-017).
8
+ */
9
+
10
+ import { existsSync } from "node:fs";
11
+ import path from "node:path";
12
+
13
+ import { linkTargets } from "../links.mjs";
14
+ import { tierAdrFiles } from "../registry.mjs";
15
+ import {
16
+ joinUnnormalized,
17
+ readText,
18
+ resolvePath,
19
+ sortByCodePoint,
20
+ } from "../text.mjs";
21
+
22
+ const LOCAL_LINK_PREFIX = "./";
23
+
24
+ /**
25
+ * Ejecuta la regla sobre los tres tiers.
26
+ * @param {ReturnType<import("../registry.mjs").createRegistry>} registry
27
+ * @param {ReturnType<import("../report.mjs").createReport>} report
28
+ * @returns {void}
29
+ */
30
+ export function ruleIndices(registry, report) {
31
+ for (const tier of registry.tiers) {
32
+ checkTierIndex({ registry, tier, report });
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Comprueba el índice de un tier en sus dos direcciones.
38
+ * @param {{ registry: object, tier: object, report: object }} context
39
+ * @returns {void}
40
+ */
41
+ function checkTierIndex(context) {
42
+ const index = path.join(context.tier.directory, "index.md");
43
+ if (!existsSync(index)) {
44
+ const rel = path.relative(context.registry.root, index);
45
+ context.report.add(
46
+ "indices",
47
+ `falta el índice del tier ${context.tier.prefix}: ${rel}`,
48
+ );
49
+ return;
50
+ }
51
+
52
+ const indexTargets = new Set(
53
+ linkTargets(readText(index)).filter(
54
+ (target) =>
55
+ target.startsWith(LOCAL_LINK_PREFIX) && target.endsWith(".md"),
56
+ ),
57
+ );
58
+
59
+ checkListedAdrs({ ...context, indexTargets });
60
+ checkExistingTargets({ ...context, index, indexTargets });
61
+ }
62
+
63
+ /**
64
+ * Reporta los ADRs del tier que no aparecen en su índice.
65
+ * @param {{ registry: object, tier: object, report: object, indexTargets: Set<string> }} context
66
+ * @returns {void}
67
+ */
68
+ function checkListedAdrs(context) {
69
+ for (const adr of tierAdrFiles(context.tier)) {
70
+ const target = `${LOCAL_LINK_PREFIX}${path.basename(adr)}`;
71
+ if (!context.indexTargets.has(target)) {
72
+ const rel = path.relative(context.registry.root, adr);
73
+ context.report.add(
74
+ "indices",
75
+ `${rel} no aparece en el índice de su tier`,
76
+ );
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Reporta los destinos del índice que no existen, en orden canónico.
83
+ * @param {{ registry: object, tier: object, report: object, index: string, indexTargets: Set<string> }} context
84
+ * @returns {void}
85
+ */
86
+ function checkExistingTargets(context) {
87
+ for (const target of sortByCodePoint([...context.indexTargets])) {
88
+ const relative = target.slice(LOCAL_LINK_PREFIX.length);
89
+ const resolved = resolvePath(
90
+ joinUnnormalized(context.tier.directory, relative),
91
+ );
92
+ if (!existsSync(resolved)) {
93
+ const rel = path.relative(context.registry.root, context.index);
94
+ context.report.add(
95
+ "indices",
96
+ `el índice ${rel} lista un ADR inexistente: ${target}`,
97
+ );
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Regla `jerarquia`: un ADR A/T/P no referencia tiers prohibidos, ni por enlace
3
+ * markdown ni por cita en texto plano. Los IDs citados por un enlace prohibido
4
+ * quedan cubiertos por esa violación y no se reportan otra vez.
5
+ */
6
+
7
+ import path from "node:path";
8
+
9
+ import { resolveLink } from "../links.mjs";
10
+ import { hasTier, targetTier, tierAdrFiles } from "../registry.mjs";
11
+ import { LINK_RE, LINK_TARGET_RE, PLAIN_ID_RE, readText } from "../text.mjs";
12
+
13
+ /**
14
+ * Ejecuta la regla sobre los tres tiers.
15
+ * @param {ReturnType<import("../registry.mjs").createRegistry>} registry
16
+ * @param {ReturnType<import("../report.mjs").createReport>} report
17
+ * @returns {void}
18
+ */
19
+ export function ruleJerarquia(registry, report) {
20
+ for (const tier of registry.tiers) {
21
+ const allowedPrefixes = new Set(
22
+ [...tier.allowed].filter((target) => hasTier(registry, target)),
23
+ );
24
+
25
+ for (const adr of tierAdrFiles(tier)) {
26
+ const text = readText(adr);
27
+ const context = {
28
+ registry,
29
+ tier,
30
+ adr,
31
+ report,
32
+ allowedPrefixes,
33
+ reportedIds: new Set(),
34
+ };
35
+
36
+ scanLinks(context, text);
37
+ scanPlainIds(context, text.replace(LINK_TARGET_RE, ""));
38
+ }
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Reporta los enlaces markdown a tiers prohibidos.
44
+ * @param {object} context
45
+ * @param {string} text
46
+ * @returns {void}
47
+ */
48
+ function scanLinks(context, text) {
49
+ for (const match of text.matchAll(LINK_RE)) {
50
+ const label = match[1];
51
+ const target = match[2];
52
+ const resolved = resolveLink(context.adr, target);
53
+ if (resolved === null) {
54
+ continue;
55
+ }
56
+
57
+ const resolvedTier = targetTier(context.registry, resolved);
58
+ if (resolvedTier === null || context.tier.allowed.has(resolvedTier)) {
59
+ continue;
60
+ }
61
+
62
+ addReportedIds(context.reportedIds, `${label} ${target}`);
63
+ context.report.add(
64
+ "jerarquia",
65
+ `${path.relative(context.registry.root, context.adr)} referencia al tier ${resolvedTier} (${target}), prohibido para el tier ${context.tier.prefix}`,
66
+ );
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Reporta las citas en texto plano con un prefijo no permitido para el tier.
72
+ * @param {object} context
73
+ * @param {string} text
74
+ * @returns {void}
75
+ */
76
+ function scanPlainIds(context, text) {
77
+ for (const match of text.matchAll(PLAIN_ID_RE)) {
78
+ const prefix = match[1];
79
+ const ident = match[0];
80
+ if (context.allowedPrefixes.has(prefix) || context.reportedIds.has(ident)) {
81
+ continue;
82
+ }
83
+ context.reportedIds.add(ident);
84
+ context.report.add(
85
+ "jerarquia",
86
+ `${path.relative(context.registry.root, context.adr)} cita en texto plano ${ident} (prefijo ${prefix}), prohibido para el tier ${context.tier.prefix}`,
87
+ );
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Marca los IDs citados por un enlace prohibido —en la etiqueta o el destino—
93
+ * para no reportarlos también como cita en texto plano.
94
+ * @param {Set<string>} reportedIds
95
+ * @param {string} text
96
+ * @returns {void}
97
+ */
98
+ function addReportedIds(reportedIds, text) {
99
+ for (const match of text.matchAll(PLAIN_ID_RE)) {
100
+ reportedIds.add(match[0]);
101
+ }
102
+ }
@@ -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
+ }
@@ -1,9 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Asegura que el CLI se publique con bit de ejecución.
3
+ * Asegura que el CLI y el gate de ADRs se publiquen con bit de ejecución.
4
4
  *
5
5
  * El blob de git solo distingue ejecutable / no ejecutable, y `npm pack`
6
- * conserva ese modo: un `bin/cli.js` en 100644 produce un tarball que algunos
6
+ * conserva ese modo: un binario en 100644 produce un tarball que algunos
7
7
  * gestores de paquetes no reparan. Se ejecuta en `prepack` para que el
8
8
  * artefacto sea correcto por sí mismo (A-007, A-052 §4).
9
9
  *
@@ -11,6 +11,11 @@
11
11
  */
12
12
  import { chmodSync } from "node:fs";
13
13
 
14
+ const EXECUTABLES = ["bin/cli.js", "scripts/verify-adr-consistency.mjs"];
15
+ const EXECUTABLE_MODE = 0o755;
16
+
14
17
  if (process.platform !== "win32") {
15
- chmodSync("bin/cli.js", 0o755);
18
+ for (const executable of EXECUTABLES) {
19
+ chmodSync(executable, EXECUTABLE_MODE);
20
+ }
16
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.py` 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.
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: `python3 scripts/verify-adr-consistency.py`. Exit `0` is the completion criterion.
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.