@imolko/ultra-agent 0.2.0-beta → 1.0.0

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.
@@ -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())