@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,59 @@
1
+ /**
2
+ * Enlaces markdown del registro ADR.
3
+ *
4
+ * `resolveLink()` reproduce `resolve_link()` del gate histórico: descarta URLs
5
+ * externas, autolinks y anclas puras, ignora fragmentos y resuelve contra el
6
+ * directorio del ADR con la semántica de `Path.resolve()`.
7
+ */
8
+
9
+ import path from "node:path";
10
+
11
+ import {
12
+ LINK_DEST_RE,
13
+ joinUnnormalized,
14
+ pyStrip,
15
+ resolvePath,
16
+ } from "./text.mjs";
17
+
18
+ /**
19
+ * Devuelve los destinos de los enlaces markdown inline `](dest)`.
20
+ * @param {string} text
21
+ * @returns {string[]}
22
+ */
23
+ export function linkTargets(text) {
24
+ return Array.from(text.matchAll(LINK_DEST_RE), (match) => match[1]);
25
+ }
26
+
27
+ /**
28
+ * Resuelve un target de enlace relativo contra el directorio del ADR.
29
+ * @param {string} adrFile
30
+ * @param {string} target
31
+ * @returns {string | null} ruta resuelta, o null si la regla no la considera
32
+ */
33
+ export function resolveLink(adrFile, target) {
34
+ let value = pyStrip(target);
35
+ if (isOutsideGate(value)) {
36
+ return null;
37
+ }
38
+
39
+ value = value.split("#", 1)[0];
40
+ if (!value || !value.endsWith(".md")) {
41
+ return null;
42
+ }
43
+
44
+ return resolvePath(joinUnnormalized(path.dirname(adrFile), value));
45
+ }
46
+
47
+ /**
48
+ * ¿El destino queda fuera del gate (URL, autolink o ancla pura)?
49
+ * @param {string} value
50
+ * @returns {boolean}
51
+ */
52
+ function isOutsideGate(value) {
53
+ return (
54
+ value.startsWith("<") ||
55
+ value.startsWith(">") ||
56
+ value.includes("://") ||
57
+ value.startsWith("#")
58
+ );
59
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Registro ADR y orden de recorrido.
3
+ *
4
+ * El gate resuelve su raíz desde su propia ubicación (no desde `process.cwd()`),
5
+ * así que `createRegistry()` recibe esa raíz y deriva las rutas del registro.
6
+ * Los tiers se recorren siempre en el orden A, T, P.
7
+ */
8
+
9
+ import { readdirSync } from "node:fs";
10
+ import path from "node:path";
11
+
12
+ import { comparePathComponents } from "./text.mjs";
13
+
14
+ const TIER_DEFINITIONS = Object.freeze([
15
+ { prefix: "A", segment: "foundational", allowed: new Set(["A", "meta"]) },
16
+ { prefix: "T", segment: "typescript", allowed: new Set(["A", "T", "meta"]) },
17
+ { prefix: "P", segment: "python", allowed: new Set(["A", "P", "meta"]) },
18
+ ]);
19
+
20
+ /**
21
+ * Construye las rutas del registro a partir de la raíz del repositorio.
22
+ * @param {string} root
23
+ * @returns {{
24
+ * root: string,
25
+ * adrDir: string,
26
+ * tiers: Array<{ prefix: string, segment: string, allowed: Set<string>, directory: string }>,
27
+ * }}
28
+ */
29
+ export function createRegistry(root) {
30
+ const adrDir = path.join(root, "docs", "adr");
31
+ const tiers = TIER_DEFINITIONS.map((tier) => ({
32
+ ...tier,
33
+ directory: path.join(adrDir, tier.segment),
34
+ }));
35
+ return { root, adrDir, tiers };
36
+ }
37
+
38
+ /**
39
+ * ¿El prefijo corresponde a un tier con directorio propio?
40
+ * @param {{ tiers: Array<{ prefix: string }> }} registry
41
+ * @param {string} prefix
42
+ * @returns {boolean}
43
+ */
44
+ export function hasTier(registry, prefix) {
45
+ return registry.tiers.some((tier) => tier.prefix === prefix);
46
+ }
47
+
48
+ /**
49
+ * Lista los ADRs de un tier (`<tier>-*.md`) ordenados por componentes de ruta,
50
+ * como `sorted(Path.glob(...))` de Python.
51
+ * @param {{ prefix: string, directory: string }} tier
52
+ * @returns {string[]}
53
+ */
54
+ export function tierAdrFiles(tier) {
55
+ let names;
56
+ try {
57
+ names = readdirSync(tier.directory);
58
+ } catch (error) {
59
+ if (error.code === "ENOENT") {
60
+ return [];
61
+ }
62
+ throw error;
63
+ }
64
+
65
+ return names
66
+ .filter((name) => name.startsWith(`${tier.prefix}-`) && name.endsWith(".md"))
67
+ .map((name) => path.join(tier.directory, name))
68
+ .sort(comparePathComponents);
69
+ }
70
+
71
+ /**
72
+ * Lista recursivamente los `*.md` de docs/adr, ordenados por componentes de
73
+ * ruta como el `sorted(ADR_DIR.rglob("*.md"))` del gate original.
74
+ * @param {string} directory
75
+ * @returns {string[]}
76
+ */
77
+ export function markdownFiles(directory) {
78
+ const files = [];
79
+ visitDirectory(directory, files);
80
+ return files.sort(comparePathComponents);
81
+ }
82
+
83
+ /**
84
+ * Acumula los `*.md` de un directorio y sus descendientes.
85
+ * @param {string} directory
86
+ * @param {string[]} files
87
+ * @returns {void}
88
+ */
89
+ function visitDirectory(directory, files) {
90
+ let entries;
91
+ try {
92
+ entries = readdirSync(directory, { withFileTypes: true });
93
+ } catch (error) {
94
+ if (error.code === "ENOENT") {
95
+ return;
96
+ }
97
+ throw error;
98
+ }
99
+
100
+ for (const entry of entries) {
101
+ const fullPath = path.join(directory, entry.name);
102
+ if (entry.isDirectory()) {
103
+ visitDirectory(fullPath, files);
104
+ } else if (entry.name.endsWith(".md")) {
105
+ files.push(fullPath);
106
+ }
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Clasifica un archivo resuelto en su tier (o null si está fuera del registro).
112
+ * @param {{ adrDir: string, tiers: Array<{ prefix: string, segment: string }> }} registry
113
+ * @param {string} resolved
114
+ * @returns {string | null}
115
+ */
116
+ export function targetTier(registry, resolved) {
117
+ const relative = path.relative(registry.adrDir, resolved);
118
+ if (!isInsideAdrDir(relative)) {
119
+ return null;
120
+ }
121
+
122
+ const parts = relative === "" ? [] : relative.split(path.sep);
123
+ if (parts.length >= 2) {
124
+ const tier = registry.tiers.find(({ segment }) => segment === parts[0]);
125
+ if (tier) {
126
+ return tier.prefix;
127
+ }
128
+ }
129
+
130
+ const name = parts.length > 0 ? parts[parts.length - 1] : "";
131
+ return isMetaName(name) ? "meta" : "project";
132
+ }
133
+
134
+ /**
135
+ * ¿La ruta relativa cae dentro de docs/adr?
136
+ * @param {string} relative
137
+ * @returns {boolean}
138
+ */
139
+ function isInsideAdrDir(relative) {
140
+ return (
141
+ relative === "" ||
142
+ (relative !== ".." &&
143
+ !relative.startsWith(`..${path.sep}`) &&
144
+ !path.isAbsolute(relative))
145
+ );
146
+ }
147
+
148
+ /**
149
+ * ¿El nombre pertenece al meta-ADR o a un índice?
150
+ * @param {string} name
151
+ * @returns {boolean}
152
+ */
153
+ function isMetaName(name) {
154
+ return name.startsWith("ADR-0000") || name === "index.md";
155
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Acumulador de violaciones del gate.
3
+ *
4
+ * Las reglas comparten un único reporte para que el orden de ejecución sea
5
+ * exactamente el de la salida (jerarquia, enlaces, frontmatter, indices,
6
+ * hardcoded): invertirlo cambia los golden files (T-017).
7
+ */
8
+
9
+ /**
10
+ * Crea el reporte de una ejecución.
11
+ * @returns {{ add: (rule: string, message: string) => void, violations: string[] }}
12
+ */
13
+ export function createReport() {
14
+ const violations = [];
15
+ return {
16
+ add(rule, message) {
17
+ violations.push(`[${rule}] ${message}`);
18
+ },
19
+ violations,
20
+ };
21
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Regla `enlaces`: todo enlace markdown interno relativo a un `*.md` resuelve a
3
+ * un archivo existente.
4
+ */
5
+
6
+ import { existsSync } from "node:fs";
7
+ import path from "node:path";
8
+
9
+ import { linkTargets, resolveLink } from "../links.mjs";
10
+ import { markdownFiles } from "../registry.mjs";
11
+ import { readText } from "../text.mjs";
12
+
13
+ /**
14
+ * Ejecuta la regla sobre todos los markdown del registro ADR.
15
+ * @param {ReturnType<import("../registry.mjs").createRegistry>} registry
16
+ * @param {ReturnType<import("../report.mjs").createReport>} report
17
+ * @returns {void}
18
+ */
19
+ export function ruleEnlaces(registry, report) {
20
+ for (const md of markdownFiles(registry.adrDir)) {
21
+ const text = readText(md);
22
+ for (const target of linkTargets(text)) {
23
+ const resolved = resolveLink(md, target);
24
+ if (resolved === null || existsSync(resolved)) {
25
+ continue;
26
+ }
27
+ report.add(
28
+ "enlaces",
29
+ `${path.relative(registry.root, md)} enlaza a un archivo inexistente: ${target}`,
30
+ );
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Regla `frontmatter`: el título H1 sigue `# <TIER>-<NNN>: Título` y los campos
3
+ * `originally` / `Enriquecido desde` usan la ortografía exacta.
4
+ */
5
+
6
+ import path from "node:path";
7
+
8
+ import { tierAdrFiles } from "../registry.mjs";
9
+ import {
10
+ ENRICHED_RE,
11
+ ENRICHED_WRONG_RE,
12
+ H1_RE,
13
+ ORIGINALLY_RE,
14
+ ORIGINALLY_WRONG_RE,
15
+ pyStrip,
16
+ readText,
17
+ splitLines,
18
+ } from "../text.mjs";
19
+
20
+ /**
21
+ * Ejecuta la regla sobre los tres tiers.
22
+ * @param {ReturnType<import("../registry.mjs").createRegistry>} registry
23
+ * @param {ReturnType<import("../report.mjs").createReport>} report
24
+ * @returns {void}
25
+ */
26
+ export function ruleFrontmatter(registry, report) {
27
+ for (const tier of registry.tiers) {
28
+ for (const adr of tierAdrFiles(tier)) {
29
+ checkFrontmatter({ registry, adr, report });
30
+ }
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Comprueba el H1 y los campos del frontmatter de un ADR.
36
+ * @param {{ registry: object, adr: string, report: object }} parent
37
+ * @returns {void}
38
+ */
39
+ function checkFrontmatter(parent) {
40
+ const text = readText(parent.adr);
41
+ const lines = splitLines(text);
42
+ const context = {
43
+ ...parent,
44
+ text,
45
+ lines,
46
+ rel: path.relative(parent.registry.root, parent.adr),
47
+ firstLine: lines[0] ?? "",
48
+ };
49
+
50
+ checkTitle(context);
51
+ checkOriginally(context);
52
+ checkEnriched(context);
53
+ }
54
+
55
+ /**
56
+ * Comprueba que el H1 siga `# <TIER>-<NNN>: Título`.
57
+ * @param {object} context
58
+ * @returns {void}
59
+ */
60
+ function checkTitle(context) {
61
+ if (!H1_RE.test(context.firstLine)) {
62
+ context.report.add(
63
+ "frontmatter",
64
+ `${context.rel}: el título H1 no sigue \`# <TIER>-<NNN>: Título\``,
65
+ );
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Comprueba que `originally` exista y esté en minúsculas.
71
+ * @param {object} context
72
+ * @returns {void}
73
+ */
74
+ function checkOriginally(context) {
75
+ if (!ORIGINALLY_RE.test(context.text)) {
76
+ context.report.add(
77
+ "frontmatter",
78
+ `${context.rel}: falta el campo \`**originally**:\` (minúsculas)`,
79
+ );
80
+ }
81
+
82
+ const spelling = {
83
+ wrong: ORIGINALLY_WRONG_RE,
84
+ right: ORIGINALLY_RE,
85
+ };
86
+ if (hasWrongSpelling(context.lines, spelling)) {
87
+ context.report.add(
88
+ "frontmatter",
89
+ `${context.rel}: \`originally\` debe ir en minúsculas`,
90
+ );
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Comprueba la ortografía del campo opcional `Enriquecido desde`.
96
+ * @param {object} context
97
+ * @returns {void}
98
+ */
99
+ function checkEnriched(context) {
100
+ const spelling = {
101
+ wrong: ENRICHED_WRONG_RE,
102
+ right: ENRICHED_RE,
103
+ };
104
+ if (hasWrongSpelling(context.lines, spelling)) {
105
+ context.report.add(
106
+ "frontmatter",
107
+ `${context.rel}: el campo opcional debe escribirse \`**Enriquecido desde**:\``,
108
+ );
109
+ }
110
+ }
111
+
112
+ /**
113
+ * ¿Alguna línea usa la variante incorrecta sin la correcta?
114
+ * @param {string[]} lines
115
+ * @param {{ wrong: RegExp, right: RegExp }} spelling
116
+ * @returns {boolean}
117
+ */
118
+ function hasWrongSpelling(lines, spelling) {
119
+ for (const line of lines) {
120
+ const stripped = pyStrip(line);
121
+ if (spelling.wrong.test(stripped) && !spelling.right.test(stripped)) {
122
+ return true;
123
+ }
124
+ }
125
+ return false;
126
+ }
@@ -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
+ }