@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.
Files changed (31) hide show
  1. package/bin/cli.js +0 -0
  2. package/dist-node/materializer.d.ts.map +1 -1
  3. package/dist-node/materializer.js +2 -1
  4. package/dist-node/materializer.js.map +1 -1
  5. package/dist-node/payload.d.ts +18 -4
  6. package/dist-node/payload.d.ts.map +1 -1
  7. package/dist-node/payload.js +27 -6
  8. package/dist-node/payload.js.map +1 -1
  9. package/dist-node/ship/ship.tracker.d.ts.map +1 -1
  10. package/dist-node/ship/ship.tracker.js +12 -11
  11. package/dist-node/ship/ship.tracker.js.map +1 -1
  12. package/docs/adr/ADR-0000-proceso-y-formato-de-adrs.md +2 -2
  13. package/docs/adr/foundational/A-042-formato-documentos-issue-tracker.md +1 -0
  14. package/docs/adr/foundational/A-052-distribucion-metodologia-paquete-versionado.md +1 -1
  15. package/eslint/index.mjs +67 -7
  16. package/package.json +13 -4
  17. package/pi/extensions/qa-guardrail/README.md +6 -2
  18. package/pi/extensions/qa-guardrail/selection.ts +41 -7
  19. package/scripts/adr-gate/links.mjs +59 -0
  20. package/scripts/adr-gate/registry.mjs +155 -0
  21. package/scripts/adr-gate/report.mjs +21 -0
  22. package/scripts/adr-gate/rules/enlaces.mjs +33 -0
  23. package/scripts/adr-gate/rules/frontmatter.mjs +126 -0
  24. package/scripts/adr-gate/rules/hardcoded.mjs +63 -0
  25. package/scripts/adr-gate/rules/indices.mjs +100 -0
  26. package/scripts/adr-gate/rules/jerarquia.mjs +102 -0
  27. package/scripts/adr-gate/text.mjs +263 -0
  28. package/scripts/ensure-bin-exec.mjs +21 -0
  29. package/scripts/verify-adr-consistency.mjs +71 -0
  30. package/skills/writing-for-agents/references/adrs.md +2 -2
  31. 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.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.
@@ -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())