agentic-workflow-manager 9.0.2 → 9.1.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.
- package/dist/src/commands/ledger/index.js +13 -0
- package/dist/src/commands/process/index.js +81 -0
- package/dist/src/core/dashboard/collect.js +30 -5
- package/dist/src/core/dashboard/sanitize.js +24 -2
- package/dist/src/core/process/body.js +181 -0
- package/dist/src/core/process/discover.js +94 -0
- package/dist/src/core/process/model.js +109 -0
- package/dist/src/core/process/types.js +5 -0
- package/dist/src/index.js +2 -0
- package/dist/tests/commands/ledger/index.test.js +11 -0
- package/dist/tests/commands/process.test.js +88 -0
- package/dist/tests/core/dashboard/collect.test.js +25 -21
- package/dist/tests/core/dashboard/processes-section.test.js +71 -0
- package/dist/tests/core/dashboard/production-adapters.test.js +58 -0
- package/dist/tests/core/process/body.test.js +239 -0
- package/dist/tests/core/process/discover.test.js +139 -0
- package/dist/tests/core/process/model.test.js +83 -0
- package/dist/tests/core/process/no-regression.test.js +57 -0
- package/dist/tests/integration/process-model-e2e.test.js +161 -0
- package/dist/tests/structural/process-model-single-parser.test.js +63 -0
- package/package.json +1 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const body_1 = require("../../../src/core/process/body");
|
|
4
|
+
const body = `
|
|
5
|
+
# Ejemplo
|
|
6
|
+
|
|
7
|
+
## Objetivo
|
|
8
|
+
|
|
9
|
+
G — Llevar una idea hasta una rama cerrada.
|
|
10
|
+
|
|
11
|
+
## Cuándo aplica
|
|
12
|
+
|
|
13
|
+
Cuando hay una tarea de desarrollo sin plan previo.
|
|
14
|
+
|
|
15
|
+
## Estructura
|
|
16
|
+
|
|
17
|
+
- SG-1 — Diseñar
|
|
18
|
+
- OP-1.1 — Elicitar requisitos
|
|
19
|
+
- OP-1.2 — Escribir el design doc
|
|
20
|
+
- SG-2 — Ejecutar
|
|
21
|
+
- OP-2.1 — Implementar por tasks
|
|
22
|
+
|
|
23
|
+
## Ruteo
|
|
24
|
+
|
|
25
|
+
| Cuándo | Estado requerido | Va a | Termina en |
|
|
26
|
+
|---|---|---|---|
|
|
27
|
+
| No hay design doc | | OP-1.1 | SG-1 |
|
|
28
|
+
| Design doc aprobado | SG-1 | OP-2.1 | SG-2 |
|
|
29
|
+
|
|
30
|
+
## Terminación
|
|
31
|
+
|
|
32
|
+
finishing-a-development-branch
|
|
33
|
+
|
|
34
|
+
## Sin verificar
|
|
35
|
+
|
|
36
|
+
- Que el usuario tenga un registry propio instalado.
|
|
37
|
+
`;
|
|
38
|
+
describe('parseProcessBody', () => {
|
|
39
|
+
it('extrae las seis secciones', () => {
|
|
40
|
+
const r = (0, body_1.parseProcessBody)(body, 'p');
|
|
41
|
+
expect(r.diagnostics).toEqual([]);
|
|
42
|
+
expect(r.model.objective).toBe('G — Llevar una idea hasta una rama cerrada.');
|
|
43
|
+
expect(r.model.appliesWhen).toBe('Cuando hay una tarea de desarrollo sin plan previo.');
|
|
44
|
+
expect(r.model.termination).toBe('finishing-a-development-branch');
|
|
45
|
+
expect(r.model.unverified).toEqual(['Que el usuario tenga un registry propio instalado.']);
|
|
46
|
+
});
|
|
47
|
+
it('descompone Estructura en SG-# con sus OP-#', () => {
|
|
48
|
+
expect((0, body_1.parseProcessBody)(body, 'p').model.structure).toEqual([
|
|
49
|
+
{ id: 'SG-1', text: 'Diseñar', operations: [
|
|
50
|
+
{ id: 'OP-1.1', text: 'Elicitar requisitos' }, { id: 'OP-1.2', text: 'Escribir el design doc' }
|
|
51
|
+
] },
|
|
52
|
+
{ id: 'SG-2', text: 'Ejecutar', operations: [{ id: 'OP-2.1', text: 'Implementar por tasks' }] },
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
it('lee Ruteo con sus cuatro columnas, incluida la vacía', () => {
|
|
56
|
+
expect((0, body_1.parseProcessBody)(body, 'p').model.routing).toEqual([
|
|
57
|
+
{ when: 'No hay design doc', requiredState: '', goesTo: 'OP-1.1', endsAt: 'SG-1' },
|
|
58
|
+
{ when: 'Design doc aprobado', requiredState: 'SG-1', goesTo: 'OP-2.1', endsAt: 'SG-2' },
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
it('conserva las filas de Ruteo como datos, sin evaluarlas ni colapsarlas', () => {
|
|
62
|
+
// WCP16 Deferred Choice: la condición se evalúa al llegar a la decisión,
|
|
63
|
+
// leyendo el estado real del proyecto. El parser no puede precomputar cuál
|
|
64
|
+
// fila gana — si lo hiciera, `show --json` emitiría una decisión ya tomada.
|
|
65
|
+
const r = (0, body_1.parseProcessBody)(body, 'p').model;
|
|
66
|
+
expect(r.routing).toHaveLength(2);
|
|
67
|
+
expect(Object.keys(r)).not.toContain('activeRoute');
|
|
68
|
+
expect(JSON.stringify(r.routing)).toContain('No hay design doc');
|
|
69
|
+
});
|
|
70
|
+
it.each(['## Objetivo', '## Ruteo', '## Terminación'])('rechaza si falta %s', (heading) => {
|
|
71
|
+
const r = (0, body_1.parseProcessBody)(body.replace(heading, '## Otra cosa'), 'p');
|
|
72
|
+
expect(r.model).toBeUndefined();
|
|
73
|
+
expect(r.diagnostics.join(' ')).toMatch(/missing/i);
|
|
74
|
+
});
|
|
75
|
+
it('rechaza una tabla de Ruteo con número de columnas equivocado', () => {
|
|
76
|
+
const r = (0, body_1.parseProcessBody)(body.replace('| No hay design doc | | OP-1.1 | SG-1 |', '| No hay design doc | OP-1.1 |'), 'p');
|
|
77
|
+
expect(r.model).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
it('rechaza un id de operación que no cuelga de su subobjetivo', () => {
|
|
80
|
+
const r = (0, body_1.parseProcessBody)(body.replace('OP-2.1', 'OP-9.1'), 'p');
|
|
81
|
+
expect(r.model).toBeUndefined();
|
|
82
|
+
expect(r.diagnostics.join(' ')).toMatch(/OP-9\.1/);
|
|
83
|
+
});
|
|
84
|
+
it('nunca lanza ante entrada basura', () => {
|
|
85
|
+
for (const junk of ['', '## Objetivo', '| | | |', '#'.repeat(500)]) {
|
|
86
|
+
expect(() => (0, body_1.parseProcessBody)(junk, 'p')).not.toThrow();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
it('rechaza un heading de nivel 2 repetido en vez de fusionarlo', () => {
|
|
90
|
+
// Un `## Ruteo` duplicado no debe fusionar sus líneas con la primera
|
|
91
|
+
// aparición: si lo hiciera, la fila de cabecera de la segunda tabla
|
|
92
|
+
// ("| Cuándo | ... |") caería en un índice no-cero del array fusionado
|
|
93
|
+
// y `parseRouting` la confundiría con una fila de datos — corrupción
|
|
94
|
+
// silenciosa, sin diagnóstico. Esto es especialmente grave porque este
|
|
95
|
+
// parser procesa contenido no confiable de registries externos.
|
|
96
|
+
const duped = `
|
|
97
|
+
## Objetivo
|
|
98
|
+
|
|
99
|
+
G — Llevar una idea hasta una rama cerrada.
|
|
100
|
+
|
|
101
|
+
## Cuándo aplica
|
|
102
|
+
|
|
103
|
+
Cuando hay una tarea de desarrollo sin plan previo.
|
|
104
|
+
|
|
105
|
+
## Estructura
|
|
106
|
+
|
|
107
|
+
- SG-1 — Diseñar
|
|
108
|
+
- OP-1.1 — Elicitar requisitos
|
|
109
|
+
|
|
110
|
+
## Ruteo
|
|
111
|
+
|
|
112
|
+
| Cuándo | Estado requerido | Va a | Termina en |
|
|
113
|
+
|---|---|---|---|
|
|
114
|
+
| No hay design doc | | OP-1.1 | SG-1 |
|
|
115
|
+
|
|
116
|
+
## Ruteo
|
|
117
|
+
|
|
118
|
+
| Cuándo | Estado requerido | Va a | Termina en |
|
|
119
|
+
|---|---|---|---|
|
|
120
|
+
| Otra condición | | OP-1.1 | SG-1 |
|
|
121
|
+
|
|
122
|
+
## Terminación
|
|
123
|
+
|
|
124
|
+
finishing-a-development-branch
|
|
125
|
+
|
|
126
|
+
## Sin verificar
|
|
127
|
+
|
|
128
|
+
- Que el usuario tenga un registry propio instalado.
|
|
129
|
+
`;
|
|
130
|
+
const r = (0, body_1.parseProcessBody)(duped, 'p');
|
|
131
|
+
expect(r.model).toBeUndefined();
|
|
132
|
+
expect(r.diagnostics.join(' ')).toMatch(/duplicate/i);
|
|
133
|
+
});
|
|
134
|
+
it('parsea un heading con una racha larga de espacios finales sin colgarse (ReDoS)', () => {
|
|
135
|
+
// Regresión: `splitSections` matcheaba headings con `/^##\s+(.+?)\s*$/`.
|
|
136
|
+
// El grupo perezoso `(.+?)` seguido de `\s*` sobre una clase que lo
|
|
137
|
+
// solapa (`.` ⊃ `\s`) es cuadrático — ver AGENTS.md
|
|
138
|
+
// "regex-cuantificador-adyacente-a-clase-que-lo-solapa". Una sola línea
|
|
139
|
+
// `## `-prefijada con una racha larga de espacios finales (ni siquiera
|
|
140
|
+
// maliciosa, un typo alcanza) colgaba el proceso. El fix usa captura
|
|
141
|
+
// greedy a fin-de-línea (`(.*)$`, sin cuantificador adyacente) y hace
|
|
142
|
+
// `.trim()` en código. Este test prueba que el parseo completo del
|
|
143
|
+
// documento sigue siendo lineal, no solo el regex aislado.
|
|
144
|
+
const longTrailingSpace = '## Objetivo' + ' '.repeat(50000) + 'x';
|
|
145
|
+
const doc = body.replace('## Objetivo', longTrailingSpace);
|
|
146
|
+
const t0 = Date.now();
|
|
147
|
+
const r = (0, body_1.parseProcessBody)(doc, 'p');
|
|
148
|
+
const elapsed = Date.now() - t0;
|
|
149
|
+
expect(elapsed).toBeLessThan(500);
|
|
150
|
+
// El heading matcheado debe llevar el trim aplicado en código, no en el regex.
|
|
151
|
+
expect(r.diagnostics.join(' ')).toMatch(/missing required section "## Objetivo"/);
|
|
152
|
+
});
|
|
153
|
+
it('ignora una línea "## " (solo espacio, sin nombre) como contenido ordinario, sin abrir una sección fantasma', () => {
|
|
154
|
+
// Regresión del fix de ReDoS: la regex vieja `/^##\s+(.+?)\s*$/` exigía
|
|
155
|
+
// al menos un carácter no-blanco capturado, así que una línea "##"
|
|
156
|
+
// seguida solo de espacio NUNCA matcheaba como heading. La regex nueva
|
|
157
|
+
// `/^##\s+(.*)$/` + trim() sí puede capturar "" — sin el guard de
|
|
158
|
+
// finding-1, esa línea abriría una sección "" fantasma y desviaría lo
|
|
159
|
+
// que sigue (aquí, una fila real de la tabla de Ruteo) hacia ella,
|
|
160
|
+
// dejando `parseProcessBody` "exitoso" pero con datos perdidos y CERO
|
|
161
|
+
// diagnósticos. La línea debe tratarse como contenido ordinario de la
|
|
162
|
+
// sección abierta (`## Ruteo`), donde `parseRouting` ya la ignora por
|
|
163
|
+
// no empezar con "|".
|
|
164
|
+
const strayHeading = body.replace('## Ruteo\n\n| Cuándo | Estado requerido | Va a | Termina en |', '## Ruteo\n\n## \n\n| Cuándo | Estado requerido | Va a | Termina en |');
|
|
165
|
+
expect(strayHeading).not.toBe(body); // guard: el replace debe haber encontrado el punto de inserción
|
|
166
|
+
const r = (0, body_1.parseProcessBody)(strayHeading, 'p');
|
|
167
|
+
expect(r.diagnostics).toEqual([]);
|
|
168
|
+
expect(r.model.routing).toEqual([
|
|
169
|
+
{ when: 'No hay design doc', requiredState: '', goesTo: 'OP-1.1', endsAt: 'SG-1' },
|
|
170
|
+
{ when: 'Design doc aprobado', requiredState: 'SG-1', goesTo: 'OP-2.1', endsAt: 'SG-2' },
|
|
171
|
+
]);
|
|
172
|
+
});
|
|
173
|
+
it('reconoce un separador de tabla con colons de alineación GFM (|:---|:---:|) sin fabricar una fila', () => {
|
|
174
|
+
// finding-2: el detector viejo `/^-{1,}$/.test(cells.join(''))` solo
|
|
175
|
+
// reconoce separadores de dashes puros. `|:---|:---:|:---:|:---:|` es
|
|
176
|
+
// sintaxis GFM estándar de alineación de columnas (muy probable si el
|
|
177
|
+
// autor del registry usó cualquier formateador de tablas markdown) y
|
|
178
|
+
// NO era reconocida — la fila colaba como dato fabricado con celdas
|
|
179
|
+
// literales como ":---". El fix la reconoce por celda con un regex
|
|
180
|
+
// anclado `^:?-+:?$` (lineal, sin riesgo ReDoS).
|
|
181
|
+
const gfmAligned = body.replace('|---|---|---|---|', '|:---|:---:|:---:|:---:|');
|
|
182
|
+
expect(gfmAligned).not.toBe(body);
|
|
183
|
+
const r = (0, body_1.parseProcessBody)(gfmAligned, 'p');
|
|
184
|
+
expect(r.diagnostics).toEqual([]);
|
|
185
|
+
expect(r.model.routing).toEqual([
|
|
186
|
+
{ when: 'No hay design doc', requiredState: '', goesTo: 'OP-1.1', endsAt: 'SG-1' },
|
|
187
|
+
{ when: 'Design doc aprobado', requiredState: 'SG-1', goesTo: 'OP-2.1', endsAt: 'SG-2' },
|
|
188
|
+
]);
|
|
189
|
+
// Ninguna fila fabricada a partir del separador (p.ej. celdas ":---").
|
|
190
|
+
expect(JSON.stringify(r.model.routing)).not.toContain(':--');
|
|
191
|
+
});
|
|
192
|
+
it.each(['Objetivo', 'Cuándo aplica', 'Terminación'])('rechaza %s presente pero con contenido vacío', (heading) => {
|
|
193
|
+
// A diferencia del test "rechaza si falta %s" (heading ausente por
|
|
194
|
+
// completo), acá el heading `## ${heading}` SÍ está presente pero no
|
|
195
|
+
// tiene contenido no-blanco debajo antes del siguiente `##` — el
|
|
196
|
+
// rechazo lo produce el chequeo `value === ''` sobre `paragraph()`,
|
|
197
|
+
// no `splitSections`, y ese camino no tenía cobertura.
|
|
198
|
+
const emptied = body.replace(new RegExp(`## ${heading}\\n\\n[^#]*`), `## ${heading}\n\n \n\n`);
|
|
199
|
+
expect(emptied).not.toBe(body);
|
|
200
|
+
const r = (0, body_1.parseProcessBody)(emptied, 'p');
|
|
201
|
+
expect(r.model).toBeUndefined();
|
|
202
|
+
expect(r.diagnostics.join(' ')).toMatch(new RegExp(`"${heading}" is empty`));
|
|
203
|
+
});
|
|
204
|
+
it('rechaza una sección de prosa que excede el largo máximo', () => {
|
|
205
|
+
// finding-1 QA: ninguna sección de texto libre tenía cota de largo —
|
|
206
|
+
// un `## Objetivo` de 10MB en un registry no confiable se aceptaba
|
|
207
|
+
// sin rechazo ni truncamiento. `MAX_SECTION_LENGTH` en body.ts cierra
|
|
208
|
+
// ese hueco para las secciones de prosa (Objetivo/Cuándo
|
|
209
|
+
// aplica/Terminación).
|
|
210
|
+
const overlong = body.replace('G — Llevar una idea hasta una rama cerrada.', 'x'.repeat(5001));
|
|
211
|
+
expect(overlong).not.toBe(body);
|
|
212
|
+
const r = (0, body_1.parseProcessBody)(overlong, 'p');
|
|
213
|
+
expect(r.model).toBeUndefined();
|
|
214
|
+
expect(r.diagnostics.join(' ')).toMatch(/"Objetivo" must be at most 5000 characters/);
|
|
215
|
+
});
|
|
216
|
+
it('rechaza un texto de SG-# que excede el largo máximo de ítem', () => {
|
|
217
|
+
// Mismo hueco que la sección de prosa, pero para los ítems
|
|
218
|
+
// estructurales (texto de SG-#/OP-#, celdas de Ruteo, entradas de
|
|
219
|
+
// "Sin verificar"), acotados por `MAX_ITEM_LENGTH` — más corto que
|
|
220
|
+
// `MAX_SECTION_LENGTH` porque son título-cortos por contrato.
|
|
221
|
+
const overlong = body.replace('- SG-1 — Diseñar', `- SG-1 — ${'x'.repeat(501)}`);
|
|
222
|
+
expect(overlong).not.toBe(body);
|
|
223
|
+
const r = (0, body_1.parseProcessBody)(overlong, 'p');
|
|
224
|
+
expect(r.model).toBeUndefined();
|
|
225
|
+
expect(r.diagnostics.join(' ')).toMatch(/"SG-1" text must be at most 500 characters/);
|
|
226
|
+
});
|
|
227
|
+
it('rechaza un SG-# duplicado en Estructura', () => {
|
|
228
|
+
// finding-3: a diferencia de la validación OP-# <-> SG-# (que sí
|
|
229
|
+
// rechaza una pertenencia inconsistente), un SG-# repetido pasaba en
|
|
230
|
+
// silencio y producía un `structure` con dos entradas compartiendo el
|
|
231
|
+
// mismo `id` — inconsistente para cualquier consumidor que indexe por
|
|
232
|
+
// id. Mismo patrón `problems` que el resto del archivo.
|
|
233
|
+
const dupedSg = body.replace('- SG-1 — Diseñar\n - OP-1.1 — Elicitar requisitos\n - OP-1.2 — Escribir el design doc\n- SG-2 — Ejecutar', '- SG-1 — Diseñar\n - OP-1.1 — Elicitar requisitos\n - OP-1.2 — Escribir el design doc\n- SG-1 — Diseñar otra vez\n- SG-2 — Ejecutar');
|
|
234
|
+
expect(dupedSg).not.toBe(body);
|
|
235
|
+
const r = (0, body_1.parseProcessBody)(dupedSg, 'p');
|
|
236
|
+
expect(r.model).toBeUndefined();
|
|
237
|
+
expect(r.diagnostics.join(' ')).toMatch(/duplicate.*SG-1/i);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
// cli/tests/core/process/discover.test.ts
|
|
7
|
+
const discover_1 = require("../../../src/core/process/discover");
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const FIXTURE = `---
|
|
12
|
+
awm: process-model
|
|
13
|
+
schema: 1
|
|
14
|
+
name: NAME
|
|
15
|
+
status: draft
|
|
16
|
+
entry_point: true
|
|
17
|
+
terminates_to: none
|
|
18
|
+
created: 2026-08-23
|
|
19
|
+
updated: 2026-08-23
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Objetivo
|
|
23
|
+
|
|
24
|
+
G — Objetivo.
|
|
25
|
+
|
|
26
|
+
## Cuándo aplica
|
|
27
|
+
|
|
28
|
+
Siempre.
|
|
29
|
+
|
|
30
|
+
## Estructura
|
|
31
|
+
|
|
32
|
+
- SG-1 — Uno
|
|
33
|
+
- OP-1.1 — Hacer
|
|
34
|
+
|
|
35
|
+
## Ruteo
|
|
36
|
+
|
|
37
|
+
| Cuándo | Estado requerido | Va a | Termina en |
|
|
38
|
+
|---|---|---|---|
|
|
39
|
+
| Al empezar | | OP-1.1 | SG-1 |
|
|
40
|
+
|
|
41
|
+
## Terminación
|
|
42
|
+
|
|
43
|
+
none
|
|
44
|
+
|
|
45
|
+
## Sin verificar
|
|
46
|
+
|
|
47
|
+
- Nada.
|
|
48
|
+
`;
|
|
49
|
+
function registry(root, skills) {
|
|
50
|
+
for (const [name, content] of Object.entries(skills)) {
|
|
51
|
+
fs_1.default.mkdirSync(path_1.default.join(root, 'skills', name), { recursive: true });
|
|
52
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills', name, 'SKILL.md'), content);
|
|
53
|
+
}
|
|
54
|
+
return root;
|
|
55
|
+
}
|
|
56
|
+
describe('discoverProcessModels', () => {
|
|
57
|
+
let tmp;
|
|
58
|
+
beforeEach(() => { tmp = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-process-discover-')); });
|
|
59
|
+
afterEach(() => { fs_1.default.rmSync(tmp, { recursive: true, force: true }); });
|
|
60
|
+
it('encuentra solo los SKILL.md con el discriminador', () => {
|
|
61
|
+
const root = registry(path_1.default.join(tmp, 'r1'), {
|
|
62
|
+
'mi-proceso': FIXTURE.replace('NAME', 'mi-proceso'),
|
|
63
|
+
'skill-normal': '---\nname: skill-normal\ndescription: otra cosa\n---\n\n# Normal\n',
|
|
64
|
+
});
|
|
65
|
+
const r = (0, discover_1.discoverProcessModels)([root]);
|
|
66
|
+
expect(r.models.map((m) => m.name)).toEqual(['mi-proceso']);
|
|
67
|
+
expect(r.diagnostics).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
it('un modelo roto no impide descubrir los sanos', () => {
|
|
70
|
+
const root = registry(path_1.default.join(tmp, 'r2'), {
|
|
71
|
+
'sano': FIXTURE.replace('NAME', 'sano'),
|
|
72
|
+
'roto': FIXTURE.replace('NAME', 'roto').replace('schema: 1', 'schema: cero'),
|
|
73
|
+
});
|
|
74
|
+
const r = (0, discover_1.discoverProcessModels)([root]);
|
|
75
|
+
expect(r.models.map((m) => m.name)).toEqual(['sano']);
|
|
76
|
+
expect(r.diagnostics).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
it('deduplica por name entre registries y lo reporta', () => {
|
|
79
|
+
const a = registry(path_1.default.join(tmp, 'a'), { 'dup': FIXTURE.replace('NAME', 'dup') });
|
|
80
|
+
const b = registry(path_1.default.join(tmp, 'b'), { 'dup': FIXTURE.replace('NAME', 'dup') });
|
|
81
|
+
const r = (0, discover_1.discoverProcessModels)([a, b]);
|
|
82
|
+
expect(r.models).toHaveLength(1);
|
|
83
|
+
expect(r.diagnostics.join(' ')).toMatch(/duplicates/i);
|
|
84
|
+
});
|
|
85
|
+
it('sin registries devuelve vacío sin diagnósticos', () => {
|
|
86
|
+
expect((0, discover_1.discoverProcessModels)([])).toEqual({ models: [], diagnostics: [] });
|
|
87
|
+
});
|
|
88
|
+
it('nunca lanza con un root inexistente', () => {
|
|
89
|
+
expect(() => (0, discover_1.discoverProcessModels)([path_1.default.join(tmp, 'no-existe')])).not.toThrow();
|
|
90
|
+
});
|
|
91
|
+
it('un `skills` roto (archivo en vez de directorio) no impide seguir, y queda diagnosticado', () => {
|
|
92
|
+
const root = path_1.default.join(tmp, 'r3');
|
|
93
|
+
fs_1.default.mkdirSync(root, { recursive: true });
|
|
94
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'skills'), 'no soy un directorio');
|
|
95
|
+
let r;
|
|
96
|
+
expect(() => { r = (0, discover_1.discoverProcessModels)([root]); }).not.toThrow();
|
|
97
|
+
expect(r.models).toEqual([]);
|
|
98
|
+
expect(r.diagnostics).toHaveLength(1);
|
|
99
|
+
expect(r.diagnostics[0]).toContain(root);
|
|
100
|
+
});
|
|
101
|
+
it('un SKILL.md roto (directorio en vez de archivo) no impide seguir, y queda diagnosticado', () => {
|
|
102
|
+
const root = path_1.default.join(tmp, 'r4');
|
|
103
|
+
const skillMd = path_1.default.join(root, 'skills', 'roto', 'SKILL.md');
|
|
104
|
+
fs_1.default.mkdirSync(skillMd, { recursive: true });
|
|
105
|
+
let r;
|
|
106
|
+
expect(() => { r = (0, discover_1.discoverProcessModels)([root]); }).not.toThrow();
|
|
107
|
+
expect(r.models).toEqual([]);
|
|
108
|
+
expect(r.diagnostics).toHaveLength(1);
|
|
109
|
+
expect(r.diagnostics[0]).toContain(skillMd);
|
|
110
|
+
expect(r.diagnostics[0]).toMatch(/cannot read/);
|
|
111
|
+
});
|
|
112
|
+
it('un SKILL.md simbolico apuntando fuera del registry no se lee: ni modelo ni contenido filtrado en diagnósticos', () => {
|
|
113
|
+
const root = path_1.default.join(tmp, 'r5');
|
|
114
|
+
const skillDir = path_1.default.join(root, 'skills', 'malicioso');
|
|
115
|
+
fs_1.default.mkdirSync(skillDir, { recursive: true });
|
|
116
|
+
// Archivo "sensible" FUERA de la raíz del registry — simula ~/.ssh/id_rsa u otro
|
|
117
|
+
// archivo local legible por el usuario que ejecuta `awm`.
|
|
118
|
+
const secretDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-outside-registry-'));
|
|
119
|
+
const secretFile = path_1.default.join(secretDir, 'id_rsa');
|
|
120
|
+
const SECRET_MARKER = 'SUPER-SECRET-PRIVATE-KEY-CONTENT-DO-NOT-LEAK';
|
|
121
|
+
fs_1.default.writeFileSync(secretFile, `-----BEGIN OPENSSH PRIVATE KEY-----\n${SECRET_MARKER}\n-----END OPENSSH PRIVATE KEY-----\n`);
|
|
122
|
+
const skillMd = path_1.default.join(skillDir, 'SKILL.md');
|
|
123
|
+
fs_1.default.symlinkSync(secretFile, skillMd);
|
|
124
|
+
let r;
|
|
125
|
+
expect(() => { r = (0, discover_1.discoverProcessModels)([root]); }).not.toThrow();
|
|
126
|
+
// Ningún modelo se construye a partir del symlink.
|
|
127
|
+
expect(r.models).toEqual([]);
|
|
128
|
+
// Se reporta el problema, pero SOLO nombrando el path — nunca el contenido del
|
|
129
|
+
// archivo apuntado. El nombre del archivo secreto en sí (id_rsa) sirve como proxy
|
|
130
|
+
// de "esto no debería aparecer en diagnósticos" tanto como el contenido.
|
|
131
|
+
expect(r.diagnostics).toHaveLength(1);
|
|
132
|
+
expect(r.diagnostics[0]).toContain(skillMd);
|
|
133
|
+
expect(r.diagnostics[0]).toMatch(/symbolic link/i);
|
|
134
|
+
const allDiagnosticsText = r.diagnostics.join('\n');
|
|
135
|
+
expect(allDiagnosticsText).not.toContain(SECRET_MARKER);
|
|
136
|
+
expect(allDiagnosticsText).not.toContain('BEGIN OPENSSH PRIVATE KEY');
|
|
137
|
+
fs_1.default.rmSync(secretDir, { recursive: true, force: true });
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const model_1 = require("../../../src/core/process/model");
|
|
4
|
+
const valid = `---
|
|
5
|
+
awm: process-model
|
|
6
|
+
schema: 1
|
|
7
|
+
name: ejemplo-proceso
|
|
8
|
+
status: draft
|
|
9
|
+
entry_point: true
|
|
10
|
+
terminates_to: none
|
|
11
|
+
created: 2026-08-23
|
|
12
|
+
updated: 2026-08-23
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Ejemplo
|
|
16
|
+
`;
|
|
17
|
+
describe('parseProcessFrontmatter', () => {
|
|
18
|
+
it('acepta el modelo canónico', () => {
|
|
19
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid, 'skills/x/SKILL.md');
|
|
20
|
+
expect(r.diagnostics).toEqual([]);
|
|
21
|
+
expect(r.model).toEqual(expect.objectContaining({
|
|
22
|
+
schema: 1, name: 'ejemplo-proceso', status: 'draft', entryPoint: true, terminatesTo: 'none',
|
|
23
|
+
}));
|
|
24
|
+
});
|
|
25
|
+
it('no infiere: sin el discriminador no es un modelo', () => {
|
|
26
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('awm: process-model\n', ''), 'skills/x/SKILL.md');
|
|
27
|
+
expect(r.model).toBeUndefined();
|
|
28
|
+
expect(r.diagnostics).toEqual([]); // no es un error: es "no es un modelo"
|
|
29
|
+
});
|
|
30
|
+
it('se detiene ante un schema más nuevo en vez de leerlo como el anterior', () => {
|
|
31
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('schema: 1', 'schema: 2'), 'skills/x/SKILL.md');
|
|
32
|
+
expect(r.model).toBeUndefined();
|
|
33
|
+
expect(r.diagnostics.join(' ')).toMatch(/newer/i);
|
|
34
|
+
});
|
|
35
|
+
it.each(['0', '-1', '1.5', 'uno'])('rechaza schema no-entero-positivo %s', (bad) => {
|
|
36
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('schema: 1', `schema: ${bad}`), 'skills/x/SKILL.md');
|
|
37
|
+
expect(r.model).toBeUndefined();
|
|
38
|
+
expect(r.diagnostics).not.toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
it.each(['active', 'draft'])('admite status %s', (s) => {
|
|
41
|
+
expect((0, model_1.parseProcessFrontmatter)(valid.replace('status: draft', `status: ${s}`), 'p').model?.status).toBe(s);
|
|
42
|
+
});
|
|
43
|
+
it('rechaza un status inventado', () => {
|
|
44
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('status: draft', 'status: published'), 'p');
|
|
45
|
+
expect(r.model).toBeUndefined();
|
|
46
|
+
expect(r.diagnostics.join(' ')).toMatch(/status/);
|
|
47
|
+
});
|
|
48
|
+
it('rechaza un campo desconocido en vez de ignorarlo', () => {
|
|
49
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('created:', 'api_token: ghp_x\ncreated:'), 'p');
|
|
50
|
+
expect(r.model).toBeUndefined();
|
|
51
|
+
expect(r.diagnostics.join(' ')).toMatch(/unknown field/i);
|
|
52
|
+
});
|
|
53
|
+
it('rechaza un name que no es slug', () => {
|
|
54
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', 'name: ../../etc/passwd'), 'p');
|
|
55
|
+
expect(r.model).toBeUndefined();
|
|
56
|
+
});
|
|
57
|
+
it('nunca lanza ante entrada basura', () => {
|
|
58
|
+
for (const junk of ['', '---\n', '---\n\x00\n---\n', 'no frontmatter']) {
|
|
59
|
+
expect(() => (0, model_1.parseProcessFrontmatter)(junk, 'p')).not.toThrow();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
it.each(['si', 'True', '1', 'yes'])('rechaza un entry_point que no es true/false: %s', (bad) => {
|
|
63
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('entry_point: true', `entry_point: ${bad}`), 'p');
|
|
64
|
+
expect(r.model).toBeUndefined();
|
|
65
|
+
expect(r.diagnostics.join(' ')).toMatch(/"entry_point" must be true or false/);
|
|
66
|
+
});
|
|
67
|
+
it.each(['../../etc/passwd', 'None', 'Foo-Bar', 'has spaces'])('rechaza un terminates_to que no es slug ni "none": %s', (bad) => {
|
|
68
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('terminates_to: none', `terminates_to: ${bad}`), 'p');
|
|
69
|
+
expect(r.model).toBeUndefined();
|
|
70
|
+
expect(r.diagnostics.join(' ')).toMatch(/"terminates_to" must be a lowercase slug or "none"/);
|
|
71
|
+
});
|
|
72
|
+
it.each(['created', 'updated'])('rechaza un %s con formato de fecha inválido', (field) => {
|
|
73
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace(`${field}: 2026-08-23`, `${field}: 23-08-2026`), 'p');
|
|
74
|
+
expect(r.model).toBeUndefined();
|
|
75
|
+
expect(r.diagnostics.join(' ')).toMatch(new RegExp(`${field}.*YYYY-MM-DD`));
|
|
76
|
+
});
|
|
77
|
+
it.each(['name', 'status', 'entry_point', 'terminates_to', 'created', 'updated'])('rechaza si falta el campo requerido %s', (field) => {
|
|
78
|
+
const withoutField = valid.split('\n').filter((line) => !line.startsWith(`${field}:`)).join('\n');
|
|
79
|
+
const r = (0, model_1.parseProcessFrontmatter)(withoutField, 'p');
|
|
80
|
+
expect(r.model).toBeUndefined();
|
|
81
|
+
expect(r.diagnostics.join(' ')).toMatch(new RegExp(`"${field}" is required`));
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
// cli/tests/core/process/no-regression.test.ts
|
|
7
|
+
const collect_1 = require("../../../src/core/dashboard/collect");
|
|
8
|
+
const discover_1 = require("../../../src/core/process/discover");
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const os_1 = __importDefault(require("os"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
describe('R7 — ausencia de modelos y aislamiento', () => {
|
|
13
|
+
let root;
|
|
14
|
+
let home;
|
|
15
|
+
const realHome = process.env.HOME;
|
|
16
|
+
const realAwmHome = process.env.AWM_HOME;
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-process-noreg-'));
|
|
19
|
+
home = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-process-home-'));
|
|
20
|
+
fs_1.default.writeFileSync(path_1.default.join(root, 'package.json'), '{}');
|
|
21
|
+
// R7.4: ningún test toca el ~/.awm real.
|
|
22
|
+
process.env.HOME = home;
|
|
23
|
+
process.env.AWM_HOME = path_1.default.join(home, '.awm');
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
if (realHome === undefined)
|
|
27
|
+
delete process.env.HOME;
|
|
28
|
+
else
|
|
29
|
+
process.env.HOME = realHome;
|
|
30
|
+
if (realAwmHome === undefined)
|
|
31
|
+
delete process.env.AWM_HOME;
|
|
32
|
+
else
|
|
33
|
+
process.env.AWM_HOME = realAwmHome;
|
|
34
|
+
fs_1.default.rmSync(root, { recursive: true, force: true });
|
|
35
|
+
fs_1.default.rmSync(home, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
it('sin modelos, el snapshot conserva las nueve secciones en orden', () => {
|
|
38
|
+
const snapshot = (0, collect_1.collectDashboardSnapshot)({ cwd: root, now: '2026-08-23T00:00:00.000Z' });
|
|
39
|
+
expect(snapshot.sections.map((s) => s.id)).toEqual(['machine', 'project', 'planning', 'execution', 'qa', 'docs', 'retro', 'history', 'processes']);
|
|
40
|
+
expect(snapshot.schema).toBe(2);
|
|
41
|
+
expect(snapshot.sections.find((s) => s.id === 'processes')).toEqual(expect.objectContaining({ availability: 'not_applicable', items: [] }));
|
|
42
|
+
});
|
|
43
|
+
it('el descubridor no toca el ~/.awm real', () => {
|
|
44
|
+
// contentRoots() (usado por default por discoverProcessModels) lee
|
|
45
|
+
// AWM_HOME dinámicamente en cada llamada — con AWM_HOME apuntando al
|
|
46
|
+
// tmpdir de este test, sin modelos de proceso instalados ahí, esto
|
|
47
|
+
// no debe lanzar. (Se descartó una aserción sobre `.awm/process-cache`:
|
|
48
|
+
// ningún código de este repo escribe ese path, así que nunca podía
|
|
49
|
+
// fallar y no aportaba evidencia real de aislamiento.)
|
|
50
|
+
expect(() => (0, discover_1.discoverProcessModels)()).not.toThrow();
|
|
51
|
+
});
|
|
52
|
+
it('usa path.join y no separadores hardcodeados', () => {
|
|
53
|
+
const src = fs_1.default.readFileSync(path_1.default.resolve(__dirname, '../../../src/core/process/discover.ts'), 'utf-8');
|
|
54
|
+
expect(src).not.toMatch(/['"`][^'"`]*\/skills\//);
|
|
55
|
+
expect(src).toContain('path.join');
|
|
56
|
+
});
|
|
57
|
+
});
|