@saulwade/swl-ses 1.4.1 → 1.4.2

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 (114) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +1 -1
  3. package/agentes/nemesis-auditor-swl.md +161 -161
  4. package/bin/swl-mcp-server.js +187 -187
  5. package/comandos/swl/.evolved.json +22 -22
  6. package/comandos/swl/contribuir.md +233 -233
  7. package/comandos/swl/nemesis.md +122 -122
  8. package/gateway/lib/event-channel.js +191 -191
  9. package/habilidades/backend-production-resilience/SKILL.md +288 -288
  10. package/habilidades/benchmark-memoria/SKILL.md +186 -186
  11. package/habilidades/diagrama-arquitectura/assets/template.html +276 -276
  12. package/habilidades/doubt-driven-review/SKILL.md +171 -171
  13. package/habilidades/doubt-driven-review/recursos/EXAMPLES.md +130 -130
  14. package/habilidades/eval-framework/SKILL.md +212 -212
  15. package/habilidades/feynman-auditor-swl/SKILL.md +123 -123
  16. package/habilidades/feynman-auditor-swl/recursos/preguntas-language-agnostic.md +108 -108
  17. package/habilidades/harness-claude-code/SKILL.md +299 -299
  18. package/habilidades/infra-github-actions/SKILL.md +166 -166
  19. package/habilidades/legacy-code-rescue/SKILL.md +267 -267
  20. package/habilidades/manejo-errores/.evolved.json +8 -8
  21. package/habilidades/meta-skills-estandar/recursos/convencion-examples.md +93 -93
  22. package/habilidades/meta-skills-estandar/recursos/skills-as-agents.md +163 -163
  23. package/habilidades/patrones-python/SKILL.md +229 -229
  24. package/habilidades/patrones-python/recursos/patrones-avanzados.md +469 -469
  25. package/habilidades/planear-fase/SKILL.md +319 -319
  26. package/habilidades/release-semver/.evolved.json +8 -8
  27. package/habilidades/state-inconsistency-auditor-swl/SKILL.md +166 -166
  28. package/habilidades/state-inconsistency-auditor-swl/recursos/coupled-state-patterns.md +147 -147
  29. package/habilidades/testing-python/SKILL.md +340 -340
  30. package/habilidades/web-fetcher-routing/SKILL.md +75 -75
  31. package/hooks/claudemd-bloat-detector.js +161 -161
  32. package/hooks/lib/agent-routing.js +107 -107
  33. package/hooks/lib/auto-consolidator.js +335 -335
  34. package/hooks/lib/error-classifier.js +308 -308
  35. package/hooks/lib/merkle-audit.js +96 -96
  36. package/hooks/lib/provenance-tracker.js +191 -191
  37. package/hooks/lib/rate-limit-tracker.js +253 -253
  38. package/hooks/lib/resource-quota.js +122 -122
  39. package/hooks/lib/retry-jitter.js +165 -165
  40. package/hooks/lib/security-net.js +201 -201
  41. package/hooks/lib/skill-auditor.js +588 -588
  42. package/hooks/lib/sync-status.js +228 -228
  43. package/hooks/lib/taint-tracker.js +107 -107
  44. package/hooks/lib/text-similarity.js +241 -241
  45. package/hooks/lib/toon-compressor.js +245 -245
  46. package/hooks/registro-turnos.js +209 -209
  47. package/hooks/sugerir-regenerar-inventario.js +170 -170
  48. package/hooks/validar-formato-post-subagente.js +140 -140
  49. package/hooks/validar-memoria-hook.js +218 -218
  50. package/instintos/prompt-appendices.yaml +57 -57
  51. package/manifiestos/agent-output-schemas.json +57 -57
  52. package/manifiestos/modulos.json +11 -6
  53. package/manifiestos/perfiles.json +2 -1
  54. package/manifiestos/skills-lock.json +1114 -1114
  55. package/package.json +1 -1
  56. package/plantillas/auditor-veto-template.md +105 -105
  57. package/plantillas/github-workflows/README.md +47 -47
  58. package/plantillas/github-workflows/release-please.yml +44 -44
  59. package/plantillas/github-workflows/swl-ci.yml +107 -107
  60. package/plantillas/github-workflows/swl-security.yml +51 -51
  61. package/plugin.json +9 -1
  62. package/reglas/analisis-previo-tareas-grandes.md +172 -172
  63. package/reglas/arreglar-al-detectar.md +147 -147
  64. package/reglas/fragmentos-compartidos.md +152 -152
  65. package/reglas/harness-claude-code.md +213 -213
  66. package/reglas/usar-context7.md +226 -226
  67. package/schemas/diary-entry.schema.json +80 -80
  68. package/scripts/audit-tools/audit-history.js +330 -330
  69. package/scripts/audit-tools/bundle-tracker.js +290 -290
  70. package/scripts/audit-tools/canary-monitor.js +352 -352
  71. package/scripts/audit-tools/code-profiler.js +605 -605
  72. package/scripts/audit-tools/dep-doctor.js +320 -320
  73. package/scripts/audit-tools/env-validator.js +206 -206
  74. package/scripts/audit-tools/lib/fs-walk.js +48 -48
  75. package/scripts/audit-tools/lib/output.js +23 -23
  76. package/scripts/audit-tools/migration-checker.js +392 -392
  77. package/scripts/audit-tools/pentest-scanner.js +1436 -1436
  78. package/scripts/benchmark-memoria.js +167 -167
  79. package/scripts/configurar-branch-protection.js +418 -418
  80. package/scripts/detectar-aprendizajes-duplicados.js +151 -151
  81. package/scripts/field-report.js +199 -199
  82. package/scripts/generar-checklists-consolidados.js +273 -273
  83. package/scripts/generar-inventario.js +420 -420
  84. package/scripts/generar-matriz-lenguajes.js +271 -271
  85. package/scripts/lib/artefactos-python.js +43 -43
  86. package/scripts/lib/benchmark-metrics.js +160 -160
  87. package/scripts/lib/budget-enforcer.js +252 -252
  88. package/scripts/lib/configurar-ci.js +380 -380
  89. package/scripts/lib/contadores-inventario.js +217 -217
  90. package/scripts/lib/detectar-stack-detallado.js +307 -307
  91. package/scripts/lib/diary-entry.js +234 -234
  92. package/scripts/lib/eval-metrics-store.js +218 -218
  93. package/scripts/lib/eval-quality.js +171 -171
  94. package/scripts/lib/eval-schemas.js +144 -144
  95. package/scripts/lib/eval-self-correct.js +106 -106
  96. package/scripts/lib/eval-validator.js +185 -185
  97. package/scripts/lib/jaccard-similarity.js +98 -98
  98. package/scripts/lib/longmemeval-runner.js +125 -125
  99. package/scripts/lib/manifiestos.js +42 -1
  100. package/scripts/lib/npm-version.js +261 -261
  101. package/scripts/lib/paquetes-conocidos.js +50 -50
  102. package/scripts/lib/prompt-builder.js +264 -264
  103. package/scripts/lib/rrf-fusion.js +175 -175
  104. package/scripts/lib/scoring-instintos.js +277 -277
  105. package/scripts/lib/semantic-search.js +252 -252
  106. package/scripts/limpiar-artefactos-python.js +131 -131
  107. package/scripts/mcp-server/README.md +128 -128
  108. package/scripts/mcp-server/handlers.js +206 -206
  109. package/scripts/migrar-csv-a-array.js +168 -168
  110. package/scripts/migrar-fase-dominio.js +201 -201
  111. package/scripts/publicar.js +511 -511
  112. package/scripts/run-eval.js +141 -141
  113. package/scripts/validar-manifest.js +231 -195
  114. package/scripts/validar-userland-vacio.js +110 -110
@@ -1,241 +1,241 @@
1
- 'use strict';
2
-
3
- /**
4
- * text-similarity.js — utilidades para fuzzy matching en español.
5
- *
6
- * Cero dependencias. Compatible Node 18+.
7
- *
8
- * Funciones expuestas:
9
- * - quitarAcentos(texto): normaliza diacríticos preservando ñ
10
- * - tokenizar(texto): divide en palabras (≥1 char)
11
- * - stemES(palabra): stemmer ligero español (Porter-light)
12
- * - levenshtein(a, b): distancia de edición
13
- * - fuzzyContains(haystack, needle, opts): true si needle aparece de forma
14
- * aproximada en haystack
15
- *
16
- * Diseño:
17
- * - El stemmer remueve UN solo sufijo por palabra (no recursivo).
18
- * - Palabras de ≤3 chars no se stemean.
19
- * - El threshold de Levenshtein es adaptativo: 0 (≤3), 1 (4-7), 2 (8+).
20
- *
21
- * Origen: ADR 0013 sección 3B (mayo 2026).
22
- */
23
-
24
- // ---------------------------------------------------------------------------
25
- // Normalización
26
- // ---------------------------------------------------------------------------
27
-
28
- const COMBINADORES_RE = /[̀-ͯ]/g;
29
- // ñ y Ñ en NFD se descomponen a n/N + U+0303. Usamos sentinels (caracteres
30
- // privados de uso +) para preservarlos sin colisión con texto real.
31
- const N_TILDE_NFD = /ñ/g;
32
- const N_TILDE_NFD_UPPER = /Ñ/g;
33
- const SENTINEL_LOWER = '';
34
- const SENTINEL_UPPER = '';
35
-
36
- /**
37
- * Remueve acentos preservando ñ. Lowercase y uppercase.
38
- * @param {string} texto
39
- * @returns {string}
40
- */
41
- function quitarAcentos(texto) {
42
- if (!texto) return '';
43
- return String(texto)
44
- .normalize('NFD')
45
- .replace(N_TILDE_NFD, SENTINEL_LOWER)
46
- .replace(N_TILDE_NFD_UPPER, SENTINEL_UPPER)
47
- .replace(COMBINADORES_RE, '')
48
- .replace(new RegExp(SENTINEL_LOWER, 'g'), 'ñ')
49
- .replace(new RegExp(SENTINEL_UPPER, 'g'), 'Ñ');
50
- }
51
-
52
- /**
53
- * Divide texto en tokens (palabras) preservando solo letras y números.
54
- * @param {string} texto
55
- * @returns {string[]}
56
- */
57
- function tokenizar(texto) {
58
- if (!texto) return [];
59
- const matches = String(texto).toLowerCase().match(/[a-záéíóúñü0-9]+/g);
60
- return matches || [];
61
- }
62
-
63
- // ---------------------------------------------------------------------------
64
- // Stemmer Porter-light español
65
- // ---------------------------------------------------------------------------
66
-
67
- /**
68
- * Sufijos ordenados de más largo a más corto. El primer match gana.
69
- * Cada entry es [sufijo, longitudMinimaResto].
70
- * Si la palabra tras quitar el sufijo es < longitudMinimaResto, no se aplica.
71
- *
72
- * Nota: el sufijo 's' tiene minResto=4 (no 3) para evitar que palabras como
73
- * "tres" o "más" se stemmen a stems de 3 chars que generan falsos matches.
74
- */
75
- const SUFIJOS_ES = [
76
- // Verbos largos (gerundio + clítico)
77
- ['iendolo', 4], ['iendola', 4], ['iendolos', 4], ['iendolas', 4],
78
- ['andolo', 4], ['andola', 4], ['andolos', 4], ['andolas', 4],
79
- // Sustantivos compuestos
80
- ['amientos', 4], ['imientos', 4], ['amiento', 4], ['imiento', 4],
81
- // Adjetivos -ación/-ición/-mente
82
- ['aciones', 4], ['iciones', 4], ['acion', 4], ['icion', 4],
83
- ['mente', 4],
84
- // Plurales -dades / abstractos
85
- ['idades', 4], ['idad', 4], ['dades', 4], ['dad', 3],
86
- // Adjetivos -ico/a + plurales
87
- ['icos', 3], ['icas', 3], ['ico', 3], ['ica', 3],
88
- // Participios
89
- ['ados', 3], ['adas', 3], ['ado', 3], ['ada', 3],
90
- ['idos', 3], ['idas', 3], ['ido', 3], ['ida', 3],
91
- // Gerundios
92
- ['iendo', 3], ['ando', 3],
93
- // Plurales / verbos comunes
94
- ['ciones', 3], ['cion', 3],
95
- // Infinitivos
96
- ['ar', 3], ['er', 3], ['ir', 3],
97
- // Plurales simples
98
- ['es', 3], ['as', 3], ['os', 3],
99
- ['s', 4],
100
- ];
101
-
102
- /**
103
- * Stemmer ligero para español. Remueve UN sufijo por palabra. No recursivo.
104
- *
105
- * @param {string} palabra
106
- * @returns {string} stem normalizado (sin acentos, lowercase)
107
- */
108
- function stemES(palabra) {
109
- if (!palabra || palabra.length <= 3) {
110
- return palabra ? quitarAcentos(palabra.toLowerCase()) : palabra;
111
- }
112
-
113
- const sin = quitarAcentos(palabra.toLowerCase());
114
-
115
- for (const [sufijo, minResto] of SUFIJOS_ES) {
116
- if (sin.endsWith(sufijo) && sin.length - sufijo.length >= minResto) {
117
- return sin.slice(0, sin.length - sufijo.length);
118
- }
119
- }
120
- return sin;
121
- }
122
-
123
- // ---------------------------------------------------------------------------
124
- // Distancia de Levenshtein (DP iterativa con dos filas)
125
- // ---------------------------------------------------------------------------
126
-
127
- /**
128
- * Distancia de edición clásica. O(min(a,b)) memoria.
129
- * @param {string} a
130
- * @param {string} b
131
- * @returns {number}
132
- */
133
- function levenshtein(a, b) {
134
- if (a === b) return 0;
135
- if (!a) return b.length;
136
- if (!b) return a.length;
137
-
138
- if (a.length > b.length) [a, b] = [b, a];
139
-
140
- let prev = new Array(a.length + 1);
141
- let curr = new Array(a.length + 1);
142
-
143
- for (let i = 0; i <= a.length; i++) prev[i] = i;
144
-
145
- for (let j = 1; j <= b.length; j++) {
146
- curr[0] = j;
147
- for (let i = 1; i <= a.length; i++) {
148
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
149
- curr[i] = Math.min(
150
- curr[i - 1] + 1,
151
- prev[i] + 1,
152
- prev[i - 1] + cost
153
- );
154
- }
155
- [prev, curr] = [curr, prev];
156
- }
157
- return prev[a.length];
158
- }
159
-
160
- // ---------------------------------------------------------------------------
161
- // Fuzzy contains
162
- // ---------------------------------------------------------------------------
163
-
164
- /**
165
- * Threshold de Levenshtein adaptativo según longitud de la palabra.
166
- * @param {string} palabra
167
- * @returns {number}
168
- */
169
- function thresholdAdaptativo(palabra) {
170
- if (palabra.length <= 3) return 0;
171
- if (palabra.length <= 7) return 1;
172
- return 2;
173
- }
174
-
175
- /**
176
- * Verifica si `needle` aparece en `haystack` con tolerancia.
177
- *
178
- * Estrategia (orden):
179
- * 1. Match exacto de substring sobre normalizado (sin acentos, lowercase).
180
- * 2. Multi-token: cada token del needle debe matchear con algún token del
181
- * haystack vía exacto, stem o Levenshtein.
182
- *
183
- * @param {string} haystack
184
- * @param {string} needle
185
- * @param {object} [opts]
186
- * @param {boolean} [opts.exact=false] - solo match exacto (sin fuzzy)
187
- * @param {boolean} [opts.useStem=true] - aplicar stemming
188
- * @param {number} [opts.threshold=null] - override threshold
189
- * @returns {boolean}
190
- */
191
- function fuzzyContains(haystack, needle, opts = {}) {
192
- const { exact = false, useStem = true, threshold = null } = opts;
193
-
194
- if (!haystack || !needle) return false;
195
-
196
- const haystackNorm = quitarAcentos(haystack.toLowerCase());
197
- const needleNorm = quitarAcentos(needle.toLowerCase());
198
-
199
- if (haystackNorm.includes(needleNorm)) return true;
200
- if (exact) return false;
201
-
202
- const needleTokens = tokenizar(needleNorm);
203
- const haystackTokens = tokenizar(haystackNorm);
204
-
205
- if (needleTokens.length === 0 || haystackTokens.length === 0) return false;
206
-
207
- return needleTokens.every((nt) =>
208
- haystackTokens.some((ht) => coincideToken(ht, nt, useStem, threshold))
209
- );
210
- }
211
-
212
- /**
213
- * Compara dos tokens individuales con tolerancia.
214
- * @param {string} ht
215
- * @param {string} nt
216
- * @param {boolean} useStem
217
- * @param {number|null} threshold
218
- * @returns {boolean}
219
- */
220
- function coincideToken(ht, nt, useStem, threshold) {
221
- if (ht === nt) return true;
222
-
223
- if (useStem) {
224
- const sht = stemES(ht);
225
- const snt = stemES(nt);
226
- if (sht === snt && sht.length >= 3) return true;
227
- }
228
-
229
- const t = threshold !== null ? threshold : thresholdAdaptativo(nt);
230
- if (t === 0) return false;
231
- return levenshtein(ht, nt) <= t;
232
- }
233
-
234
- module.exports = {
235
- quitarAcentos,
236
- tokenizar,
237
- stemES,
238
- levenshtein,
239
- fuzzyContains,
240
- _internals: { thresholdAdaptativo, coincideToken, SUFIJOS_ES },
241
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * text-similarity.js — utilidades para fuzzy matching en español.
5
+ *
6
+ * Cero dependencias. Compatible Node 18+.
7
+ *
8
+ * Funciones expuestas:
9
+ * - quitarAcentos(texto): normaliza diacríticos preservando ñ
10
+ * - tokenizar(texto): divide en palabras (≥1 char)
11
+ * - stemES(palabra): stemmer ligero español (Porter-light)
12
+ * - levenshtein(a, b): distancia de edición
13
+ * - fuzzyContains(haystack, needle, opts): true si needle aparece de forma
14
+ * aproximada en haystack
15
+ *
16
+ * Diseño:
17
+ * - El stemmer remueve UN solo sufijo por palabra (no recursivo).
18
+ * - Palabras de ≤3 chars no se stemean.
19
+ * - El threshold de Levenshtein es adaptativo: 0 (≤3), 1 (4-7), 2 (8+).
20
+ *
21
+ * Origen: ADR 0013 sección 3B (mayo 2026).
22
+ */
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Normalización
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const COMBINADORES_RE = /[̀-ͯ]/g;
29
+ // ñ y Ñ en NFD se descomponen a n/N + U+0303. Usamos sentinels (caracteres
30
+ // privados de uso +) para preservarlos sin colisión con texto real.
31
+ const N_TILDE_NFD = /ñ/g;
32
+ const N_TILDE_NFD_UPPER = /Ñ/g;
33
+ const SENTINEL_LOWER = '';
34
+ const SENTINEL_UPPER = '';
35
+
36
+ /**
37
+ * Remueve acentos preservando ñ. Lowercase y uppercase.
38
+ * @param {string} texto
39
+ * @returns {string}
40
+ */
41
+ function quitarAcentos(texto) {
42
+ if (!texto) return '';
43
+ return String(texto)
44
+ .normalize('NFD')
45
+ .replace(N_TILDE_NFD, SENTINEL_LOWER)
46
+ .replace(N_TILDE_NFD_UPPER, SENTINEL_UPPER)
47
+ .replace(COMBINADORES_RE, '')
48
+ .replace(new RegExp(SENTINEL_LOWER, 'g'), 'ñ')
49
+ .replace(new RegExp(SENTINEL_UPPER, 'g'), 'Ñ');
50
+ }
51
+
52
+ /**
53
+ * Divide texto en tokens (palabras) preservando solo letras y números.
54
+ * @param {string} texto
55
+ * @returns {string[]}
56
+ */
57
+ function tokenizar(texto) {
58
+ if (!texto) return [];
59
+ const matches = String(texto).toLowerCase().match(/[a-záéíóúñü0-9]+/g);
60
+ return matches || [];
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Stemmer Porter-light español
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Sufijos ordenados de más largo a más corto. El primer match gana.
69
+ * Cada entry es [sufijo, longitudMinimaResto].
70
+ * Si la palabra tras quitar el sufijo es < longitudMinimaResto, no se aplica.
71
+ *
72
+ * Nota: el sufijo 's' tiene minResto=4 (no 3) para evitar que palabras como
73
+ * "tres" o "más" se stemmen a stems de 3 chars que generan falsos matches.
74
+ */
75
+ const SUFIJOS_ES = [
76
+ // Verbos largos (gerundio + clítico)
77
+ ['iendolo', 4], ['iendola', 4], ['iendolos', 4], ['iendolas', 4],
78
+ ['andolo', 4], ['andola', 4], ['andolos', 4], ['andolas', 4],
79
+ // Sustantivos compuestos
80
+ ['amientos', 4], ['imientos', 4], ['amiento', 4], ['imiento', 4],
81
+ // Adjetivos -ación/-ición/-mente
82
+ ['aciones', 4], ['iciones', 4], ['acion', 4], ['icion', 4],
83
+ ['mente', 4],
84
+ // Plurales -dades / abstractos
85
+ ['idades', 4], ['idad', 4], ['dades', 4], ['dad', 3],
86
+ // Adjetivos -ico/a + plurales
87
+ ['icos', 3], ['icas', 3], ['ico', 3], ['ica', 3],
88
+ // Participios
89
+ ['ados', 3], ['adas', 3], ['ado', 3], ['ada', 3],
90
+ ['idos', 3], ['idas', 3], ['ido', 3], ['ida', 3],
91
+ // Gerundios
92
+ ['iendo', 3], ['ando', 3],
93
+ // Plurales / verbos comunes
94
+ ['ciones', 3], ['cion', 3],
95
+ // Infinitivos
96
+ ['ar', 3], ['er', 3], ['ir', 3],
97
+ // Plurales simples
98
+ ['es', 3], ['as', 3], ['os', 3],
99
+ ['s', 4],
100
+ ];
101
+
102
+ /**
103
+ * Stemmer ligero para español. Remueve UN sufijo por palabra. No recursivo.
104
+ *
105
+ * @param {string} palabra
106
+ * @returns {string} stem normalizado (sin acentos, lowercase)
107
+ */
108
+ function stemES(palabra) {
109
+ if (!palabra || palabra.length <= 3) {
110
+ return palabra ? quitarAcentos(palabra.toLowerCase()) : palabra;
111
+ }
112
+
113
+ const sin = quitarAcentos(palabra.toLowerCase());
114
+
115
+ for (const [sufijo, minResto] of SUFIJOS_ES) {
116
+ if (sin.endsWith(sufijo) && sin.length - sufijo.length >= minResto) {
117
+ return sin.slice(0, sin.length - sufijo.length);
118
+ }
119
+ }
120
+ return sin;
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Distancia de Levenshtein (DP iterativa con dos filas)
125
+ // ---------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Distancia de edición clásica. O(min(a,b)) memoria.
129
+ * @param {string} a
130
+ * @param {string} b
131
+ * @returns {number}
132
+ */
133
+ function levenshtein(a, b) {
134
+ if (a === b) return 0;
135
+ if (!a) return b.length;
136
+ if (!b) return a.length;
137
+
138
+ if (a.length > b.length) [a, b] = [b, a];
139
+
140
+ let prev = new Array(a.length + 1);
141
+ let curr = new Array(a.length + 1);
142
+
143
+ for (let i = 0; i <= a.length; i++) prev[i] = i;
144
+
145
+ for (let j = 1; j <= b.length; j++) {
146
+ curr[0] = j;
147
+ for (let i = 1; i <= a.length; i++) {
148
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
149
+ curr[i] = Math.min(
150
+ curr[i - 1] + 1,
151
+ prev[i] + 1,
152
+ prev[i - 1] + cost
153
+ );
154
+ }
155
+ [prev, curr] = [curr, prev];
156
+ }
157
+ return prev[a.length];
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Fuzzy contains
162
+ // ---------------------------------------------------------------------------
163
+
164
+ /**
165
+ * Threshold de Levenshtein adaptativo según longitud de la palabra.
166
+ * @param {string} palabra
167
+ * @returns {number}
168
+ */
169
+ function thresholdAdaptativo(palabra) {
170
+ if (palabra.length <= 3) return 0;
171
+ if (palabra.length <= 7) return 1;
172
+ return 2;
173
+ }
174
+
175
+ /**
176
+ * Verifica si `needle` aparece en `haystack` con tolerancia.
177
+ *
178
+ * Estrategia (orden):
179
+ * 1. Match exacto de substring sobre normalizado (sin acentos, lowercase).
180
+ * 2. Multi-token: cada token del needle debe matchear con algún token del
181
+ * haystack vía exacto, stem o Levenshtein.
182
+ *
183
+ * @param {string} haystack
184
+ * @param {string} needle
185
+ * @param {object} [opts]
186
+ * @param {boolean} [opts.exact=false] - solo match exacto (sin fuzzy)
187
+ * @param {boolean} [opts.useStem=true] - aplicar stemming
188
+ * @param {number} [opts.threshold=null] - override threshold
189
+ * @returns {boolean}
190
+ */
191
+ function fuzzyContains(haystack, needle, opts = {}) {
192
+ const { exact = false, useStem = true, threshold = null } = opts;
193
+
194
+ if (!haystack || !needle) return false;
195
+
196
+ const haystackNorm = quitarAcentos(haystack.toLowerCase());
197
+ const needleNorm = quitarAcentos(needle.toLowerCase());
198
+
199
+ if (haystackNorm.includes(needleNorm)) return true;
200
+ if (exact) return false;
201
+
202
+ const needleTokens = tokenizar(needleNorm);
203
+ const haystackTokens = tokenizar(haystackNorm);
204
+
205
+ if (needleTokens.length === 0 || haystackTokens.length === 0) return false;
206
+
207
+ return needleTokens.every((nt) =>
208
+ haystackTokens.some((ht) => coincideToken(ht, nt, useStem, threshold))
209
+ );
210
+ }
211
+
212
+ /**
213
+ * Compara dos tokens individuales con tolerancia.
214
+ * @param {string} ht
215
+ * @param {string} nt
216
+ * @param {boolean} useStem
217
+ * @param {number|null} threshold
218
+ * @returns {boolean}
219
+ */
220
+ function coincideToken(ht, nt, useStem, threshold) {
221
+ if (ht === nt) return true;
222
+
223
+ if (useStem) {
224
+ const sht = stemES(ht);
225
+ const snt = stemES(nt);
226
+ if (sht === snt && sht.length >= 3) return true;
227
+ }
228
+
229
+ const t = threshold !== null ? threshold : thresholdAdaptativo(nt);
230
+ if (t === 0) return false;
231
+ return levenshtein(ht, nt) <= t;
232
+ }
233
+
234
+ module.exports = {
235
+ quitarAcentos,
236
+ tokenizar,
237
+ stemES,
238
+ levenshtein,
239
+ fuzzyContains,
240
+ _internals: { thresholdAdaptativo, coincideToken, SUFIJOS_ES },
241
+ };