agentic-workflow-manager 9.20.0 → 9.20.1
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/core/process/model.js +153 -10
- package/dist/tests/commands/process.test.js +4 -0
- package/dist/tests/core/changes/helpers.js +2 -2
- package/dist/tests/core/dashboard/write-html.test.js +2 -3
- package/dist/tests/core/evidence/store.test.js +2 -3
- package/dist/tests/core/ledger/store.test.js +3 -9
- package/dist/tests/core/process/discover.test.js +21 -0
- package/dist/tests/core/process/model.test.js +338 -0
- package/dist/tests/helpers/platform.js +27 -0
- package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +6 -5
- package/dist/tests/structural/process-model-description-contract.test.js +34 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/secure_fs.node +0 -0
- package/prebuilds/darwin-x64/secure_fs.node +0 -0
- package/prebuilds/win32-arm64/secure_fs.node +0 -0
- package/prebuilds/win32-x64/secure_fs.node +0 -0
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.PROCESS_NAME = void 0;
|
|
4
7
|
exports.parseProcessFrontmatter = parseProcessFrontmatter;
|
|
@@ -7,40 +10,155 @@ exports.parseProcessFrontmatter = parseProcessFrontmatter;
|
|
|
7
10
|
// Disciplina heredada literal de core/orchestrators.ts: NUNCA lanza. Un modelo
|
|
8
11
|
// malformado se rechaza con diagnóstico y no invalida al registry que lo
|
|
9
12
|
// contiene ni a los demás (R7.1).
|
|
13
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
10
14
|
const frontmatter_1 = require("../frontmatter");
|
|
11
15
|
const types_1 = require("./types");
|
|
12
16
|
/** El discriminador es literal: ningún documento se reconoce como modelo por su
|
|
13
17
|
* cuerpo, sus headings ni su nombre de archivo (R1.2). */
|
|
14
18
|
const DISCRIMINATOR = 'process-model';
|
|
15
|
-
|
|
19
|
+
/** `description` es el único campo opcional: runtimes como Codex se niegan a
|
|
20
|
+
* cargar un `SKILL.md` sin él, y el modelo *es* el `SKILL.md` del skill. */
|
|
21
|
+
const ALLOWED_FIELDS = ['awm', 'schema', 'name', 'status', 'entry_point', 'terminates_to', 'created', 'updated', 'description'];
|
|
22
|
+
/** Límite de Agent Skills que imponen los runtimes que consumen el skill.
|
|
23
|
+
* Se cuenta en code points, no en unidades UTF-16. */
|
|
24
|
+
const DESCRIPTION_MAX = 1024;
|
|
25
|
+
/** Tope del bloque de frontmatter antes de entregarlo al parser YAML: acota el
|
|
26
|
+
* costo de parsear contenido no confiable de un registry. Por encima no se
|
|
27
|
+
* parsea (para `description`, se rechaza con el mensaje del límite de 1024). */
|
|
28
|
+
const FRONTMATTER_MAX = 65536;
|
|
29
|
+
/** Lo que un runtime mostraría mal o usaría para engañar: controles C0 (salvo
|
|
30
|
+
* tab), DEL, C1, separadores U+2028/U+2029, marcas LRM/RLM/ALM, overrides e
|
|
31
|
+
* isolates bidi, y surrogates sueltos (regex en vez de `isWellFormed`: el lib
|
|
32
|
+
* de TS es ES2022). */
|
|
33
|
+
const FORBIDDEN_CHAR = /[\u0000-\u0008\u000A-\u001F\u007F-\u009F\u061C\u200E\u200F\u2028\u2029\u202A-\u202E\u2066-\u2069]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
34
|
+
/** Discriminador indentado: un modelo cuyas claves quedaron con sangría se
|
|
35
|
+
* perdería en silencio como "no es un modelo". */
|
|
36
|
+
const INDENTED_DISCRIMINATOR = /^awm[ \t]*:[ \t]*process-model$/;
|
|
16
37
|
const STATUSES = ['draft', 'active'];
|
|
17
38
|
/** El slug es lo único del modelo que puede viajar dentro de un id del Dashboard
|
|
18
39
|
* (ver sanitize.ts). Por eso se valida acá y no en la frontera de render: si
|
|
19
40
|
* admitiera rutas, markup o `=`, el id dejaría de ser seguro por construcción. */
|
|
20
41
|
exports.PROCESS_NAME = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
21
42
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
43
|
+
/** Caracteres de ancho cero: un valor hecho solo de ellos se ve vacío. */
|
|
44
|
+
const ZERO_WIDTH = /[\u200B-\u200D\u2060\uFEFF]/g;
|
|
22
45
|
/** Lector de pares `clave: valor` de un frontmatter plano. El contrato no admite
|
|
23
|
-
* anidamiento ni block scalars
|
|
24
|
-
*
|
|
46
|
+
* anidamiento ni block scalars: cualquier línea que no sea `clave: valor` es un
|
|
47
|
+
* rechazo, no una interpretación. (El VALOR de `description` sí pasa por el
|
|
48
|
+
* parser YAML, más abajo.) */
|
|
25
49
|
function readPairs(block) {
|
|
26
50
|
const pairs = new Map();
|
|
27
51
|
const problems = [];
|
|
28
|
-
|
|
29
|
-
|
|
52
|
+
const continuations = [];
|
|
53
|
+
// Mismo criterio que YAML, no que JS: se parte en TODO salto de línea YAML
|
|
54
|
+
// (un CR suelto también; si no, `a\rclave: x` sería un par para acá y dos
|
|
55
|
+
// claves para un runtime) y solo se recorta espacio y tab (`\s`/`trim()`
|
|
56
|
+
// comerían NBSP o U+3000, que para YAML son contenido).
|
|
57
|
+
const lines = block.split(/\r\n|\r|\n/);
|
|
58
|
+
for (const [index, raw] of lines.entries()) {
|
|
59
|
+
const line = raw.replace(/^[ \t]+|[ \t]+$/g, '');
|
|
60
|
+
// NEL/LS/PS son saltos de línea para YAML 1.1 (PyYAML) y no para js-yaml:
|
|
61
|
+
// un comentario podría esconder una clave que solo un runtime ve. Se
|
|
62
|
+
// reporta en CUALQUIER línea, comentarios incluidos, y solo por posición.
|
|
63
|
+
if (/[\u0085\u2028\u2029]/.test(raw))
|
|
64
|
+
problems.push(`frontmatter line ${index + 1} contains a YAML 1.1 line break (NEL, LS or PS)`);
|
|
30
65
|
if (line === '' || line.startsWith('#'))
|
|
31
66
|
continue;
|
|
32
|
-
|
|
67
|
+
// El contrato es un mapa PLANO: una línea que empieza con espacio o tab
|
|
68
|
+
// es continuación de un valor multilínea, diga lo que diga. Si se
|
|
69
|
+
// recortara antes de mirar, ` KEY: x` pasaría por campo (y su nombre
|
|
70
|
+
// terminaría en un diagnóstico).
|
|
71
|
+
const indented = /^[ \t]/.test(raw);
|
|
72
|
+
if (indented)
|
|
73
|
+
continuations.push({ line: index + 1, text: line });
|
|
74
|
+
// `[\s\S]` y no `.`: un U+2028/U+2029 dentro del valor no lo vuelve
|
|
75
|
+
// "no-par" (la línea ya viene partida); lo rechaza el chequeo de controles.
|
|
76
|
+
const m = indented ? null : /^([A-Za-z_][A-Za-z0-9_]*)[ \t]*:[ \t]*([\s\S]*)$/.exec(line);
|
|
77
|
+
// Se reporta la POSICIÓN (1-based dentro del bloque), nunca el
|
|
78
|
+
// contenido: una línea suelta suele ser la continuación de un valor
|
|
79
|
+
// multilínea, y los diagnósticos no transportan valores de campos.
|
|
33
80
|
if (!m) {
|
|
34
|
-
problems.push(`line is not a "key: value" pair
|
|
81
|
+
problems.push(`frontmatter line ${index + 1} is not a "key: value" pair (multi-line values are not supported)`);
|
|
35
82
|
continue;
|
|
36
83
|
}
|
|
37
84
|
if (pairs.has(m[1])) {
|
|
38
85
|
problems.push(`duplicate field ${JSON.stringify(m[1])}`);
|
|
39
86
|
continue;
|
|
40
87
|
}
|
|
41
|
-
pairs.set(m[1], m[2]
|
|
88
|
+
pairs.set(m[1], m[2]);
|
|
42
89
|
}
|
|
43
|
-
return { pairs, problems };
|
|
90
|
+
return { pairs, problems, continuations };
|
|
91
|
+
}
|
|
92
|
+
/** Carga el bloque de frontmatter COMPLETO con js-yaml (schema por defecto),
|
|
93
|
+
* exactamente como lo lee un runtime. Por encima del tope no se parsea. NUNCA
|
|
94
|
+
* lanza: un error de js-yaml es `invalid`. */
|
|
95
|
+
function loadBlock(block) {
|
|
96
|
+
if (block.length > FRONTMATTER_MAX)
|
|
97
|
+
return { kind: 'too-long' };
|
|
98
|
+
try {
|
|
99
|
+
return { kind: 'loaded', doc: js_yaml_1.default.load(block) };
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { kind: 'invalid' };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function isPlainObject(doc) {
|
|
106
|
+
return doc !== null && typeof doc === 'object' && Object.getPrototypeOf(doc) === Object.prototype;
|
|
107
|
+
}
|
|
108
|
+
/** Valida `description` leyéndola del bloque entero con el parser YAML real, no
|
|
109
|
+
* de la salida de readPairs (ya recortada y partida en `:`, que difiere de lo
|
|
110
|
+
* que lee un runtime). Ni el valor ni el texto del error de js-yaml (que puede
|
|
111
|
+
* citar contenido) llegan al problema. */
|
|
112
|
+
/** ¿El valor crudo de la línea, cargado SOLO, da el mismo string que el bloque
|
|
113
|
+
* entero? Si no, YAML lo plegó con líneas siguientes (p. ej. una comilla que
|
|
114
|
+
* sigue abierta a través de una línea `# …`). NUNCA lanza. */
|
|
115
|
+
function readsSameAlone(raw, value) {
|
|
116
|
+
let alone;
|
|
117
|
+
try {
|
|
118
|
+
alone = js_yaml_1.default.load(`description: ${raw}`);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return isPlainObject(alone) && alone.description === value;
|
|
124
|
+
}
|
|
125
|
+
function checkDescription(raw, block, keys) {
|
|
126
|
+
const singleLine = { problem: '"description" must be a single-line string (block scalars are not supported in a process model)' };
|
|
127
|
+
// El prefijo alcanza: todo header de block scalar (bien o mal formado)
|
|
128
|
+
// empieza con `>` o `|`.
|
|
129
|
+
if (/^[>|]/.test(raw))
|
|
130
|
+
return singleLine;
|
|
131
|
+
const tooLong = { problem: `"description" must be at most ${DESCRIPTION_MAX} characters` };
|
|
132
|
+
const loaded = loadBlock(block);
|
|
133
|
+
if (loaded.kind === 'too-long')
|
|
134
|
+
return tooLong;
|
|
135
|
+
if (loaded.kind === 'invalid' || !isPlainObject(loaded.doc))
|
|
136
|
+
return { problem: '"description" cannot be read: the frontmatter is not valid YAML' };
|
|
137
|
+
const doc = loaded.doc;
|
|
138
|
+
// Si YAML lee otras claves de nivel superior que readPairs (un CR suelto,
|
|
139
|
+
// una comilla que se traga la línea siguiente), lo validado acá no es lo
|
|
140
|
+
// que verá un runtime. Ni claves ni valores van al problema.
|
|
141
|
+
const yamlKeys = Object.keys(doc);
|
|
142
|
+
if (yamlKeys.length !== keys.size || yamlKeys.some((k) => !keys.has(k))) {
|
|
143
|
+
return { problem: '"description" cannot be read: the frontmatter does not read the same as YAML (check quotes and line breaks)' };
|
|
144
|
+
}
|
|
145
|
+
const value = doc.description;
|
|
146
|
+
const empty = { problem: '"description" must not be empty' };
|
|
147
|
+
if (value === null || value === undefined)
|
|
148
|
+
return empty;
|
|
149
|
+
if (typeof value !== 'string') {
|
|
150
|
+
return { problem: '"description" must be a string (quote values YAML reads as a boolean, a number, a date or a collection)' };
|
|
151
|
+
}
|
|
152
|
+
if (value.replace(ZERO_WIDTH, '').trim() === '')
|
|
153
|
+
return empty;
|
|
154
|
+
if ([...value].length > DESCRIPTION_MAX)
|
|
155
|
+
return tooLong;
|
|
156
|
+
if (FORBIDDEN_CHAR.test(value))
|
|
157
|
+
return { problem: '"description" must not contain control, line-break or bidirectional-override characters' };
|
|
158
|
+
if (!readsSameAlone(raw, value))
|
|
159
|
+
return singleLine;
|
|
160
|
+
// Se guarda tal cual lo devuelve YAML: sin trim, con NBSP/BOM intactos.
|
|
161
|
+
return { value };
|
|
44
162
|
}
|
|
45
163
|
function parseProcessFrontmatter(source, file) {
|
|
46
164
|
let block;
|
|
@@ -52,7 +170,22 @@ function parseProcessFrontmatter(source, file) {
|
|
|
52
170
|
}
|
|
53
171
|
if (block === null)
|
|
54
172
|
return { diagnostics: [] };
|
|
55
|
-
const { pairs, problems } = readPairs(block);
|
|
173
|
+
const { pairs, problems, continuations } = readPairs(block);
|
|
174
|
+
// Un modelo con las claves indentadas no tiene `awm` de nivel superior y se
|
|
175
|
+
// perdería en silencio como "otro documento". Se nombra solo la línea.
|
|
176
|
+
// Dispara con el prefiltro barato y, además, si el bloque NO carga como YAML
|
|
177
|
+
// (sangría parcial o con tab; por encima del tope cuenta como no cargable)
|
|
178
|
+
// o carga con `awm: process-model` en su nivel superior. Si carga sin `awm`
|
|
179
|
+
// arriba (mención bajo `metadata:` o dentro de un block scalar), silencio.
|
|
180
|
+
if (!pairs.has('awm')) {
|
|
181
|
+
const indentedAwm = continuations.find((c) => INDENTED_DISCRIMINATOR.test(c.text));
|
|
182
|
+
const loaded = indentedAwm ? loadBlock(block) : undefined;
|
|
183
|
+
const fires = loaded !== undefined
|
|
184
|
+
&& (loaded.kind !== 'loaded' || (isPlainObject(loaded.doc) && loaded.doc.awm === DISCRIMINATOR));
|
|
185
|
+
if (indentedAwm && fires) {
|
|
186
|
+
return { diagnostics: [`${file}: invalid process model — frontmatter keys must not be indented (frontmatter line ${indentedAwm.line})`] };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
56
189
|
// Sin discriminador NO es un modelo de proceso: no es un error, es otro
|
|
57
190
|
// documento. Devolver diagnóstico acá inundaría de ruido a todo registry
|
|
58
191
|
// (cada SKILL.md normal caería en esta rama).
|
|
@@ -96,6 +229,15 @@ function parseProcessFrontmatter(source, file) {
|
|
|
96
229
|
if (value !== '' && !DATE.test(value))
|
|
97
230
|
problems.push(`"${field}" must be YYYY-MM-DD`);
|
|
98
231
|
}
|
|
232
|
+
let description;
|
|
233
|
+
const rawDescription = pairs.get('description');
|
|
234
|
+
if (rawDescription !== undefined) {
|
|
235
|
+
const checked = checkDescription(rawDescription, block, new Set(pairs.keys()));
|
|
236
|
+
if ('problem' in checked)
|
|
237
|
+
problems.push(checked.problem);
|
|
238
|
+
else
|
|
239
|
+
description = checked.value;
|
|
240
|
+
}
|
|
99
241
|
if (problems.length > 0) {
|
|
100
242
|
return { diagnostics: [`${file}: invalid process model — ${problems.join('; ')}`] };
|
|
101
243
|
}
|
|
@@ -103,6 +245,7 @@ function parseProcessFrontmatter(source, file) {
|
|
|
103
245
|
model: {
|
|
104
246
|
schema, name, status: status, entryPoint: entryPointRaw === 'true',
|
|
105
247
|
terminatesTo, created: pairs.get('created'), updated: pairs.get('updated'),
|
|
248
|
+
...(description !== undefined ? { description } : {}),
|
|
106
249
|
},
|
|
107
250
|
diagnostics: [],
|
|
108
251
|
};
|
|
@@ -42,6 +42,10 @@ describe('awm process show --json', () => {
|
|
|
42
42
|
expect(parsed).toEqual(expect.objectContaining({ name: 'mi-proceso', schema: 1, status: 'draft' }));
|
|
43
43
|
expect(parsed.body.routing).toEqual([{ when: 'Al empezar', requiredState: '', goesTo: 'OP-1.1', endsAt: 'SG-1' }]);
|
|
44
44
|
});
|
|
45
|
+
it('emite description cuando el modelo la trae', () => {
|
|
46
|
+
const parsed = JSON.parse((0, process_1.runProcessShow)({ models: [model({ description: 'Use when tracing' })], diagnostics: [] }, 'mi-proceso', true).stdout);
|
|
47
|
+
expect(parsed.description).toBe('Use when tracing');
|
|
48
|
+
});
|
|
45
49
|
it('el JSON no filtra el path del filesystem del registry', () => {
|
|
46
50
|
const parsed = JSON.parse((0, process_1.runProcessShow)({ models: [model()], diagnostics: [] }, 'mi-proceso', true).stdout);
|
|
47
51
|
expect(JSON.stringify(parsed)).not.toContain('/r/skills');
|
|
@@ -135,8 +135,8 @@ function gitAtLeast(major, minor) {
|
|
|
135
135
|
const v = gitVersion();
|
|
136
136
|
return v !== null && (v[0] > major || (v[0] === major && v[1] >= minor));
|
|
137
137
|
}
|
|
138
|
-
|
|
139
|
-
exports
|
|
138
|
+
var platform_1 = require("../../helpers/platform");
|
|
139
|
+
Object.defineProperty(exports, "posixOnly", { enumerable: true, get: function () { return platform_1.posixOnly; } });
|
|
140
140
|
/**
|
|
141
141
|
* A copy of `template` in its own directory (same throwaway home, same git isolation): a few milliseconds instead of the
|
|
142
142
|
* several git processes `makeRepo` spawns. For suites that need a fresh repository per test. `cleanup` removes only the copy;
|
|
@@ -7,6 +7,7 @@ const fs_1 = __importDefault(require("fs"));
|
|
|
7
7
|
const os_1 = __importDefault(require("os"));
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const write_html_1 = require("../../../src/core/dashboard/write-html");
|
|
10
|
+
const platform_1 = require("../../helpers/platform");
|
|
10
11
|
describe('writeHtmlAtomically', () => {
|
|
11
12
|
let root;
|
|
12
13
|
beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-html-')); });
|
|
@@ -85,9 +86,7 @@ describe('writeHtmlAtomically', () => {
|
|
|
85
86
|
open.mockRestore();
|
|
86
87
|
}
|
|
87
88
|
});
|
|
88
|
-
|
|
89
|
-
if (process.platform === 'win32')
|
|
90
|
-
return;
|
|
89
|
+
(0, platform_1.posixOnly)('uses mode 0600 for a POSIX adjacent temporary file', () => {
|
|
91
90
|
const target = path_1.default.join(root, 'posix.html');
|
|
92
91
|
const open = jest.spyOn(fs_1.default, 'openSync');
|
|
93
92
|
try {
|
|
@@ -8,6 +8,7 @@ const os_1 = __importDefault(require("os"));
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const evidence_fixtures_1 = require("../../helpers/evidence-fixtures");
|
|
10
10
|
const store_1 = require("../../../src/core/evidence/store");
|
|
11
|
+
const platform_1 = require("../../helpers/platform");
|
|
11
12
|
describe('cycle evidence store', () => {
|
|
12
13
|
let root;
|
|
13
14
|
beforeEach(() => { root = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-evidence-')); });
|
|
@@ -20,9 +21,7 @@ describe('cycle evidence store', () => {
|
|
|
20
21
|
expect(fs_1.default.readdirSync(dir)).toEqual([`${evidence.cycleId}.json`]);
|
|
21
22
|
expect(JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, `${evidence.cycleId}.json`), 'utf8')).qa.fixes).toBe(0);
|
|
22
23
|
});
|
|
23
|
-
|
|
24
|
-
if (process.platform === 'win32')
|
|
25
|
-
return;
|
|
24
|
+
(0, platform_1.posixOnly)('rejects symlinked evidence ancestors without writing outside root', () => {
|
|
26
25
|
const outside = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-evidence-outside-'));
|
|
27
26
|
fs_1.default.symlinkSync(outside, path_1.default.join(root, '.awm'));
|
|
28
27
|
expect(() => (0, store_1.writeCycleEvidence)(root, (0, evidence_fixtures_1.cycleEvidenceFixture)())).toThrow(/symlink/);
|
|
@@ -8,6 +8,7 @@ const os_1 = __importDefault(require("os"));
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const store_1 = require("../../../src/core/ledger/store");
|
|
10
10
|
const types_1 = require("../../../src/core/ledger/types");
|
|
11
|
+
const platform_1 = require("../../helpers/platform");
|
|
11
12
|
function mkTmp() {
|
|
12
13
|
return fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-ledger-'));
|
|
13
14
|
}
|
|
@@ -191,18 +192,11 @@ describe('ledger store — archive', () => {
|
|
|
191
192
|
expect((0, store_1.listEntries)(cwd, 'feat-x')).toEqual([expect.objectContaining({ signature: 'active-evidence' })]);
|
|
192
193
|
expect(fs_1.default.readFileSync(archivePath, 'utf-8')).toContain('archived-evidence');
|
|
193
194
|
});
|
|
194
|
-
|
|
195
|
+
(0, platform_1.symlinkTest)('refuses a dangling symlink archive destination and preserves the active ledger', () => {
|
|
195
196
|
(0, store_1.addEntry)(cwd, entry({ signature: 'active-evidence' }));
|
|
196
197
|
const archivePath = path_1.default.join(cwd, '.awm', 'ledger', 'archive', 'feat-x-20260606T000000.jsonl');
|
|
197
198
|
fs_1.default.mkdirSync(path_1.default.dirname(archivePath), { recursive: true });
|
|
198
|
-
|
|
199
|
-
fs_1.default.symlinkSync(path_1.default.join(cwd, 'missing-target.jsonl'), archivePath);
|
|
200
|
-
}
|
|
201
|
-
catch (error) {
|
|
202
|
-
if (process.platform === 'win32')
|
|
203
|
-
return;
|
|
204
|
-
throw error;
|
|
205
|
-
}
|
|
199
|
+
fs_1.default.symlinkSync(path_1.default.join(cwd, 'missing-target.jsonl'), archivePath);
|
|
206
200
|
expect(() => (0, store_1.archiveLedger)(cwd, 'feat-x', '20260606T000000')).toThrow(/archive.*already exists/i);
|
|
207
201
|
expect(fs_1.default.lstatSync(archivePath).isSymbolicLink()).toBe(true);
|
|
208
202
|
expect((0, store_1.listEntries)(cwd, 'feat-x')).toEqual([expect.objectContaining({ signature: 'active-evidence' })]);
|
|
@@ -66,6 +66,27 @@ describe('discoverProcessModels', () => {
|
|
|
66
66
|
expect(r.models.map((m) => m.name)).toEqual(['mi-proceso']);
|
|
67
67
|
expect(r.diagnostics).toEqual([]);
|
|
68
68
|
});
|
|
69
|
+
it('description sobrevive parse → discover, tal cual la lee YAML', () => {
|
|
70
|
+
const saved = { HOME: process.env.HOME, AWM_HOME: process.env.AWM_HOME };
|
|
71
|
+
process.env.HOME = path_1.default.join(tmp, 'home');
|
|
72
|
+
process.env.AWM_HOME = path_1.default.join(tmp, 'home', '.awm');
|
|
73
|
+
try {
|
|
74
|
+
const root = registry(path_1.default.join(tmp, 'rd'), {
|
|
75
|
+
'con-desc': FIXTURE.replace('NAME', 'con-desc').replace('created:', 'description: "Use when: tracing\\u00A0"\ncreated:'),
|
|
76
|
+
});
|
|
77
|
+
const r = (0, discover_1.discoverProcessModels)([root]);
|
|
78
|
+
expect(r.diagnostics).toEqual([]);
|
|
79
|
+
expect(r.models.map((m) => m.description)).toEqual(['Use when: tracing ']);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
83
|
+
if (v === undefined)
|
|
84
|
+
delete process.env[k];
|
|
85
|
+
else
|
|
86
|
+
process.env[k] = v;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
});
|
|
69
90
|
it('un modelo roto no impide descubrir los sanos', () => {
|
|
70
91
|
const root = registry(path_1.default.join(tmp, 'r2'), {
|
|
71
92
|
'sano': FIXTURE.replace('NAME', 'sano'),
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
const js_yaml_1 = __importDefault(require("js-yaml"));
|
|
3
7
|
const model_1 = require("../../../src/core/process/model");
|
|
4
8
|
const valid = `---
|
|
5
9
|
awm: process-model
|
|
@@ -80,4 +84,338 @@ describe('parseProcessFrontmatter', () => {
|
|
|
80
84
|
expect(r.model).toBeUndefined();
|
|
81
85
|
expect(r.diagnostics.join(' ')).toMatch(new RegExp(`"${field}" is required`));
|
|
82
86
|
});
|
|
87
|
+
describe('description (optional)', () => {
|
|
88
|
+
const withDescription = (line) => valid.replace('created:', `${line}\ncreated:`);
|
|
89
|
+
it('is optional: a model without it parses unchanged and has no description key', () => {
|
|
90
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid, 'p');
|
|
91
|
+
expect(r.diagnostics).toEqual([]);
|
|
92
|
+
expect(r.model).toBeDefined();
|
|
93
|
+
expect(Object.prototype.hasOwnProperty.call(r.model, 'description')).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
it.each([
|
|
96
|
+
['plain', 'description: Use when tracing a work session', 'Use when tracing a work session'],
|
|
97
|
+
['double-quoted', 'description: "Use when: tracing \\"sessions\\""', 'Use when: tracing "sessions"'],
|
|
98
|
+
['single-quoted', "description: 'It''s the entry point'", "It's the entry point"],
|
|
99
|
+
])('accepts a %s single-line value and exposes it resolved', (_kind, line, expected) => {
|
|
100
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(line), 'p');
|
|
101
|
+
expect(r.diagnostics).toEqual([]);
|
|
102
|
+
expect(r.model?.description).toBe(expected);
|
|
103
|
+
});
|
|
104
|
+
it('accepts exactly 1024 characters and rejects 1025', () => {
|
|
105
|
+
expect((0, model_1.parseProcessFrontmatter)(withDescription(`description: ${'a'.repeat(1024)}`), 'p').model?.description).toHaveLength(1024);
|
|
106
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${'a'.repeat(1025)}`), 'p');
|
|
107
|
+
expect(r.model).toBeUndefined();
|
|
108
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" must be at most 1024 characters/);
|
|
109
|
+
});
|
|
110
|
+
it.each(['description:', 'description: ""', "description: ''", 'description: " "'])('rejects an empty value: %s', (line) => {
|
|
111
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(line), 'p');
|
|
112
|
+
expect(r.model).toBeUndefined();
|
|
113
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" must not be empty/);
|
|
114
|
+
});
|
|
115
|
+
it.each(['description: >', 'description: >-', 'description: |', 'description: |2+', 'description: >-basura'])('rejects a block scalar header: %s', (line) => {
|
|
116
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`${line}\n folded text`), 'p');
|
|
117
|
+
expect(r.model).toBeUndefined();
|
|
118
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" must be a single-line string \(block scalars are not supported in a process model\)/);
|
|
119
|
+
});
|
|
120
|
+
it('still rejects every other unknown field next to a valid description', () => {
|
|
121
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: ok\nsummary: nope'), 'p');
|
|
122
|
+
expect(r.model).toBeUndefined();
|
|
123
|
+
expect(r.diagnostics.join(' ')).toMatch(/unknown field "summary"/);
|
|
124
|
+
expect(r.diagnostics.join(' ')).not.toMatch(/unknown field "description"/);
|
|
125
|
+
});
|
|
126
|
+
it('rejects a duplicated description', () => {
|
|
127
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: a\ndescription: b'), 'p');
|
|
128
|
+
expect(r.model).toBeUndefined();
|
|
129
|
+
expect(r.diagnostics.join(' ')).toMatch(/duplicate field "description"/);
|
|
130
|
+
});
|
|
131
|
+
it.each([
|
|
132
|
+
['a block scalar', 'description: >-\n SECRETVALUE here'],
|
|
133
|
+
['a plain multi-line value', 'description: first\n SECRETVALUE second'],
|
|
134
|
+
['a quote closed on a later line', 'description: "hola\n SECRETVALUE mundo"'],
|
|
135
|
+
])('reports %s by line position and never echoes its content', (_kind, lines) => {
|
|
136
|
+
// Bloque del fixture: awm(1) … terminates_to(6); la línea description
|
|
137
|
+
// queda en 7 y su continuación en 8.
|
|
138
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(lines), 'p');
|
|
139
|
+
expect(r.model).toBeUndefined();
|
|
140
|
+
for (const d of r.diagnostics)
|
|
141
|
+
expect(d).not.toContain('SECRETVALUE');
|
|
142
|
+
expect(r.diagnostics.join(' ')).toContain('frontmatter line 8 is not a "key: value" pair (multi-line values are not supported)');
|
|
143
|
+
});
|
|
144
|
+
it.each([
|
|
145
|
+
['a plain value', 'description: Use for\n SECRETKEY: when X', false],
|
|
146
|
+
['a block scalar', 'description: >-\n SECRETKEY: x', true],
|
|
147
|
+
])('treats an indented "KEY: x" after %s as a continuation, never as a field', (_kind, lines, isBlock) => {
|
|
148
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(lines), 'p');
|
|
149
|
+
expect(r.model).toBeUndefined();
|
|
150
|
+
for (const d of r.diagnostics)
|
|
151
|
+
expect(d).not.toContain('SECRETKEY');
|
|
152
|
+
expect(r.diagnostics.join(' ')).toContain('frontmatter line 8 is not a "key: value" pair (multi-line values are not supported)');
|
|
153
|
+
if (isBlock) {
|
|
154
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" must be a single-line string \(block scalars are not supported in a process model\)/);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
const PARSE_ERROR = /"description" cannot be read: the frontmatter is not valid YAML/;
|
|
158
|
+
const NOT_A_STRING = /"description" must be a string \(quote values YAML reads as a boolean, a number, a date or a collection\)/;
|
|
159
|
+
it.each([
|
|
160
|
+
'- foo', '? foo', 'a:\tb', 'Use when: tracing', '"hola', "'hola", "'hola # x", '"hola # x', '"a" junk',
|
|
161
|
+
'ends with colon:', '@x', '\x1b',
|
|
162
|
+
])('rejects a value YAML cannot parse as a single-line string: %j', (value) => {
|
|
163
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
|
|
164
|
+
expect(r.model).toBeUndefined();
|
|
165
|
+
expect(r.diagnostics.join(' ')).toMatch(PARSE_ERROR);
|
|
166
|
+
});
|
|
167
|
+
it.each(['true', '123', '2026-01-01', '[a, b]'])('rejects a value YAML reads as a non-string: %s', (value) => {
|
|
168
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
|
|
169
|
+
expect(r.model).toBeUndefined();
|
|
170
|
+
expect(r.diagnostics.join(' ')).toMatch(NOT_A_STRING);
|
|
171
|
+
});
|
|
172
|
+
it.each(['null', '~', '# x'])('rejects a value YAML reads as null as empty: %s', (value) => {
|
|
173
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
|
|
174
|
+
expect(r.model).toBeUndefined();
|
|
175
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" must not be empty/);
|
|
176
|
+
});
|
|
177
|
+
it('never echoes the value or the YAML error text into a diagnostic', () => {
|
|
178
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: SECRETVALUE: x'), 'p');
|
|
179
|
+
expect(r.diagnostics.join(' ')).toMatch(PARSE_ERROR);
|
|
180
|
+
for (const d of r.diagnostics)
|
|
181
|
+
expect(d).not.toContain('SECRETVALUE');
|
|
182
|
+
});
|
|
183
|
+
it('resolves YAML double-quoted escapes', () => {
|
|
184
|
+
expect((0, model_1.parseProcessFrontmatter)(withDescription('description: "\\x41"'), 'p').model?.description).toBe('A');
|
|
185
|
+
});
|
|
186
|
+
it('stores the YAML string untrimmed', () => {
|
|
187
|
+
expect((0, model_1.parseProcessFrontmatter)(withDescription('description: " padded "'), 'p').model?.description).toBe(' padded ');
|
|
188
|
+
});
|
|
189
|
+
it.each([
|
|
190
|
+
['description: "Use when: tracing"', 'Use when: tracing'],
|
|
191
|
+
["description: 'It''s'", "It's"],
|
|
192
|
+
])('accepts the quoted form %s', (line, expected) => {
|
|
193
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(line), 'p');
|
|
194
|
+
expect(r.diagnostics).toEqual([]);
|
|
195
|
+
expect(r.model?.description).toBe(expected);
|
|
196
|
+
});
|
|
197
|
+
const blockOf = (source) => source.slice(4, source.indexOf('\n---', 4));
|
|
198
|
+
/** Modelo válido con description `ok` cuyo bloque de frontmatter mide exactamente `target`. */
|
|
199
|
+
const withBlockLength = (target) => {
|
|
200
|
+
const base = withDescription('description: ok');
|
|
201
|
+
const pad = target - blockOf(base).length - 3;
|
|
202
|
+
const source = base.replace('\ncreated:', `\n# ${'x'.repeat(pad)}\ncreated:`);
|
|
203
|
+
expect(blockOf(source)).toHaveLength(target);
|
|
204
|
+
return source;
|
|
205
|
+
};
|
|
206
|
+
it('parses a frontmatter block of exactly 65536 characters', () => {
|
|
207
|
+
const r = (0, model_1.parseProcessFrontmatter)(withBlockLength(65536), 'p');
|
|
208
|
+
expect(r.diagnostics).toEqual([]);
|
|
209
|
+
expect(r.model?.description).toBe('ok');
|
|
210
|
+
});
|
|
211
|
+
it('rejects a frontmatter block over 65536 characters with the 1024 message without parsing it', () => {
|
|
212
|
+
const source = withBlockLength(65537);
|
|
213
|
+
const load = jest.spyOn(js_yaml_1.default, 'load');
|
|
214
|
+
try {
|
|
215
|
+
const r = (0, model_1.parseProcessFrontmatter)(source, 'p');
|
|
216
|
+
expect(r.model).toBeUndefined();
|
|
217
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" must be at most 1024 characters/);
|
|
218
|
+
expect(load).not.toHaveBeenCalled();
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
load.mockRestore();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
it('accepts a short value followed by a 20000-character comment', () => {
|
|
225
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ok # ${'x'.repeat(20000)}`), 'p');
|
|
226
|
+
expect(r.diagnostics).toEqual([]);
|
|
227
|
+
expect(r.model?.description).toBe('ok');
|
|
228
|
+
});
|
|
229
|
+
it('accepts an escape-heavy value that resolves to 1024 code points', () => {
|
|
230
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: "${'\\x41'.repeat(1024)}"`), 'p');
|
|
231
|
+
expect(r.diagnostics).toEqual([]);
|
|
232
|
+
expect(r.model?.description).toBe('A'.repeat(1024));
|
|
233
|
+
});
|
|
234
|
+
it('preserves NBSP, U+3000 and BOM exactly as YAML reads them', () => {
|
|
235
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: \u00A0hello\u3000world\uFEFF'), 'p');
|
|
236
|
+
expect(r.diagnostics).toEqual([]);
|
|
237
|
+
expect(r.model?.description).toBe('\u00A0hello\u3000world\uFEFF');
|
|
238
|
+
});
|
|
239
|
+
it('rejects description:value with no space after the colon', () => {
|
|
240
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description:value'), 'p');
|
|
241
|
+
expect(r.model).toBeUndefined();
|
|
242
|
+
expect(r.diagnostics.join(' ')).toMatch(PARSE_ERROR);
|
|
243
|
+
});
|
|
244
|
+
const CONTROL = /"description" must not contain control, line-break or bidirectional-override characters/;
|
|
245
|
+
it.each([
|
|
246
|
+
'"a\\nb"', '"a\\rb"', '"\\0"', '"\\e[31m"', '"\\x7f"', '"\\x85"', '"\\x9f"',
|
|
247
|
+
'"a\\u2028b"', '"a\\u2029b"', '"\\u202A"', '"\\u202E"', '"\\u2066"', '"\\u2069"',
|
|
248
|
+
'"\\uD800"', '"\\uDC00"', 'a\u2028b', 'a\u2029b', 'a\u202Eb', 'a\u0085b',
|
|
249
|
+
])('rejects control, line-break, bidi or lone-surrogate content: %j', (value) => {
|
|
250
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
|
|
251
|
+
expect(r.model).toBeUndefined();
|
|
252
|
+
expect(r.diagnostics.join(' ')).toMatch(CONTROL);
|
|
253
|
+
});
|
|
254
|
+
it.each([
|
|
255
|
+
['an escaped tab', '"a\\tb"', 'a\tb'],
|
|
256
|
+
['a surrogate pair', '"\\uD83D\\uDE00"', '\u{1F600}'],
|
|
257
|
+
])('accepts %s', (_kind, value, expected) => {
|
|
258
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
|
|
259
|
+
expect(r.diagnostics).toEqual([]);
|
|
260
|
+
expect(r.model?.description).toBe(expected);
|
|
261
|
+
});
|
|
262
|
+
it.each(['"\\u200B"', '"\\u200B\\u200C\\u200D"', '"\\u2060\\uFEFF "'])('rejects a value made only of zero-width characters as empty: %j', (value) => {
|
|
263
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
|
|
264
|
+
expect(r.model).toBeUndefined();
|
|
265
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" must not be empty/);
|
|
266
|
+
});
|
|
267
|
+
it.each(['"a\\bb"', '"a\\x1fb"', '"a\\u200Eb"', '"a\\u200Fb"', '"a\\u061Cb"'])('rejects backspace, US and LRM/RLM/ALM: %j', (value) => {
|
|
268
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${value}`), 'p');
|
|
269
|
+
expect(r.model).toBeUndefined();
|
|
270
|
+
expect(r.diagnostics.join(' ')).toMatch(CONTROL);
|
|
271
|
+
});
|
|
272
|
+
const DIVERGES = /"description" cannot be read: the frontmatter does not read the same as YAML \(check quotes and line breaks\)/;
|
|
273
|
+
it('rejects a bare CR that smuggles a second top-level key', () => {
|
|
274
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: hello\rallowed-tools: x'), 'p');
|
|
275
|
+
expect(r.model).toBeUndefined();
|
|
276
|
+
expect(r.diagnostics.join(' ')).toMatch(DIVERGES);
|
|
277
|
+
for (const d of r.diagnostics)
|
|
278
|
+
expect(d).not.toContain('allowed-tools');
|
|
279
|
+
});
|
|
280
|
+
it('rejects an unclosed quote that YAML folds into the following name line', () => {
|
|
281
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', 'description: "hola\nname: ejemplo-proceso"'), 'p');
|
|
282
|
+
expect(r.model).toBeUndefined();
|
|
283
|
+
expect(r.diagnostics.join(' ')).toMatch(DIVERGES);
|
|
284
|
+
});
|
|
285
|
+
it('validates a description that follows a comment on a bare CR', () => {
|
|
286
|
+
const bad = (0, model_1.parseProcessFrontmatter)(withDescription('#\rdescription: 123'), 'p');
|
|
287
|
+
expect(bad.model).toBeUndefined();
|
|
288
|
+
expect(bad.diagnostics.join(' ')).toMatch(NOT_A_STRING);
|
|
289
|
+
const ok = (0, model_1.parseProcessFrontmatter)(withDescription('#\rdescription: ok'), 'p');
|
|
290
|
+
expect(ok.diagnostics).toEqual([]);
|
|
291
|
+
expect(ok.model?.description).toBe('ok');
|
|
292
|
+
});
|
|
293
|
+
it('reads NBSP followed by > as a string, not as a block-scalar header', () => {
|
|
294
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: \u00A0>'), 'p');
|
|
295
|
+
expect(r.diagnostics).toEqual([]);
|
|
296
|
+
expect(r.model?.description).toBe('\u00A0>');
|
|
297
|
+
});
|
|
298
|
+
it.each([['NEL', '\u0085'], ['LS', '\u2028'], ['PS', '\u2029']])('rejects a %s inside a comment line by position', (_kind, ch) => {
|
|
299
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(`# SECRET${ch}name: smuggled`), 'p');
|
|
300
|
+
expect(r.model).toBeUndefined();
|
|
301
|
+
expect(r.diagnostics.join(' ')).toContain('frontmatter line 7 contains a YAML 1.1 line break (NEL, LS or PS)');
|
|
302
|
+
for (const d of r.diagnostics)
|
|
303
|
+
expect(d).not.toContain('SECRET');
|
|
304
|
+
});
|
|
305
|
+
it.each([['NEL', '\u0085'], ['LS', '\u2028'], ['PS', '\u2029']])('rejects a %s inside a value line of any field by position', (_kind, ch) => {
|
|
306
|
+
const inDescription = (0, model_1.parseProcessFrontmatter)(withDescription(`description: a${ch}b`), 'p');
|
|
307
|
+
expect(inDescription.model).toBeUndefined();
|
|
308
|
+
expect(inDescription.diagnostics.join(' ')).toContain('frontmatter line 7 contains a YAML 1.1 line break (NEL, LS or PS)');
|
|
309
|
+
const inName = (0, model_1.parseProcessFrontmatter)(valid.replace('name: ejemplo-proceso', `name: ejemplo${ch}proceso`), 'p');
|
|
310
|
+
expect(inName.model).toBeUndefined();
|
|
311
|
+
expect(inName.diagnostics.join(' ')).toContain('frontmatter line 3 contains a YAML 1.1 line break (NEL, LS or PS)');
|
|
312
|
+
});
|
|
313
|
+
it.each(['description: "foo\n# bar"', "description: 'foo\n# bar'"])('rejects a quoted value that YAML folds across a following comment line: %j', (lines) => {
|
|
314
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(lines), 'p');
|
|
315
|
+
expect(r.model).toBeUndefined();
|
|
316
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" must be a single-line string \(block scalars are not supported in a process model\)/);
|
|
317
|
+
for (const d of r.diagnostics)
|
|
318
|
+
expect(d).not.toContain('bar');
|
|
319
|
+
});
|
|
320
|
+
it.each([
|
|
321
|
+
['description: "Use when: tracing a work session"', 'Use when: tracing a work session'],
|
|
322
|
+
['description: Use when tracing a work session # nota', 'Use when tracing a work session'],
|
|
323
|
+
["description: 'It''s the entry point' # nota", "It's the entry point"],
|
|
324
|
+
['description: "Traza la sesión \\u2014 sin perder pasos"\n# comentario aparte', 'Traza la sesión \u2014 sin perder pasos'],
|
|
325
|
+
])('keeps accepting a realistic description: %j', (lines, expected) => {
|
|
326
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription(lines), 'p');
|
|
327
|
+
expect(r.diagnostics).toEqual([]);
|
|
328
|
+
expect(r.model?.description).toBe(expected);
|
|
329
|
+
});
|
|
330
|
+
it('lists description in the unknown-field message', () => {
|
|
331
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('summary: nope'), 'p');
|
|
332
|
+
expect(r.diagnostics.join(' ')).toMatch(/the contract admits only [^;]*\bdescription\b/);
|
|
333
|
+
});
|
|
334
|
+
it('accepts a closed quote followed by a comment', () => {
|
|
335
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: "a" # note'), 'p');
|
|
336
|
+
expect(r.diagnostics).toEqual([]);
|
|
337
|
+
expect(r.model?.description).toBe('a');
|
|
338
|
+
});
|
|
339
|
+
it('rejects a comment-only value as empty and never echoes it', () => {
|
|
340
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: # x'), 'p');
|
|
341
|
+
expect(r.model).toBeUndefined();
|
|
342
|
+
expect(r.diagnostics.join(' ')).toMatch(/"description" must not be empty/);
|
|
343
|
+
for (const d of r.diagnostics)
|
|
344
|
+
expect(d).not.toContain('# x');
|
|
345
|
+
});
|
|
346
|
+
it('counts the 1024 limit in code points, not UTF-16 units', () => {
|
|
347
|
+
const ok = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${'😀'.repeat(1024)}`), 'p');
|
|
348
|
+
expect(ok.diagnostics).toEqual([]);
|
|
349
|
+
expect([...(ok.model?.description ?? '')]).toHaveLength(1024);
|
|
350
|
+
const tooLong = (0, model_1.parseProcessFrontmatter)(withDescription(`description: ${'😀'.repeat(1025)}`), 'p');
|
|
351
|
+
expect(tooLong.model).toBeUndefined();
|
|
352
|
+
expect(tooLong.diagnostics.join(' ')).toMatch(/"description" must be at most 1024 characters/);
|
|
353
|
+
});
|
|
354
|
+
it('keeps skipping an indented comment line after a valid description', () => {
|
|
355
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: ok\n # comentario'), 'p');
|
|
356
|
+
expect(r.diagnostics).toEqual([]);
|
|
357
|
+
expect(r.model?.description).toBe('ok');
|
|
358
|
+
});
|
|
359
|
+
it('accepts a plain value followed by a comment, without the comment', () => {
|
|
360
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: plain text # note'), 'p');
|
|
361
|
+
expect(r.diagnostics).toEqual([]);
|
|
362
|
+
expect(r.model?.description).toBe('plain text');
|
|
363
|
+
});
|
|
364
|
+
it('accepts escaped quotes inside a closed double-quoted value', () => {
|
|
365
|
+
const r = (0, model_1.parseProcessFrontmatter)(withDescription('description: "a \\"b\\""'), 'p');
|
|
366
|
+
expect(r.diagnostics).toEqual([]);
|
|
367
|
+
expect(r.model?.description).toBe('a "b"');
|
|
368
|
+
});
|
|
369
|
+
it('never throws on hostile description values', () => {
|
|
370
|
+
for (const line of ['description: "unclosed', "description: '''", 'description: \x00', `description: ${'"'.repeat(5000)}`]) {
|
|
371
|
+
expect(() => (0, model_1.parseProcessFrontmatter)(withDescription(line), 'p')).not.toThrow();
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
describe('indented frontmatter keys', () => {
|
|
376
|
+
const indentAll = (source) => source.replace(/^---\n([\s\S]*?)\n---/, (_m, block) => `---\n${block.split('\n').map((l) => ` ${l}`).join('\n')}\n---`);
|
|
377
|
+
it('reports a model whose keys are all indented instead of ignoring it', () => {
|
|
378
|
+
const r = (0, model_1.parseProcessFrontmatter)(indentAll(valid), 'p');
|
|
379
|
+
expect(r.model).toBeUndefined();
|
|
380
|
+
expect(r.diagnostics).toEqual(['p: invalid process model — frontmatter keys must not be indented (frontmatter line 1)']);
|
|
381
|
+
});
|
|
382
|
+
it('allows whitespace around the colon and counts comment lines', () => {
|
|
383
|
+
const r = (0, model_1.parseProcessFrontmatter)('---\n# nota\n awm : process-model\n name: x\n---\n', 'p');
|
|
384
|
+
expect(r.diagnostics).toEqual(['p: invalid process model — frontmatter keys must not be indented (frontmatter line 2)']);
|
|
385
|
+
});
|
|
386
|
+
it.each([
|
|
387
|
+
['another indented value', '---\nname: x\nmetadata:\n awm: other\n---\n'],
|
|
388
|
+
['a mention nested under metadata', '---\nname: x\nmetadata:\n awm: process-model\n---\n'],
|
|
389
|
+
['a mention inside a block-scalar description', '---\nname: x\ndescription: >\n awm: process-model\n---\n'],
|
|
390
|
+
['a near-miss discriminator', '---\n awm: process-model-x\n name: x\n---\n'],
|
|
391
|
+
])('stays silent for a non-model with %s', (_kind, source) => {
|
|
392
|
+
expect((0, model_1.parseProcessFrontmatter)(source, 'p')).toEqual({ diagnostics: [] });
|
|
393
|
+
});
|
|
394
|
+
it.each([
|
|
395
|
+
['only the awm line indented', valid.replace('awm: process-model', ' awm: process-model')],
|
|
396
|
+
['a tab-indented awm line', valid.replace('awm: process-model', '\tawm: process-model')],
|
|
397
|
+
])('reports %s even though YAML cannot load the block', (_kind, source) => {
|
|
398
|
+
expect((0, model_1.parseProcessFrontmatter)(source, 'p')).toEqual({
|
|
399
|
+
diagnostics: ['p: invalid process model — frontmatter keys must not be indented (frontmatter line 1)'],
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
it('gives no indented diagnostic to a top-level-awm model with a nested metadata.awm', () => {
|
|
403
|
+
const r = (0, model_1.parseProcessFrontmatter)(valid.replace('created:', 'metadata:\n awm: process-model\ncreated:'), 'p');
|
|
404
|
+
expect(r.model).toBeUndefined();
|
|
405
|
+
expect(r.diagnostics.join(' ')).not.toMatch(/must not be indented/);
|
|
406
|
+
});
|
|
407
|
+
it('reports an indented discriminator in a block over 65536 characters without loading it', () => {
|
|
408
|
+
const source = indentAll(valid.replace('\ncreated:', `\n# ${'x'.repeat(65536)}\ncreated:`));
|
|
409
|
+
const load = jest.spyOn(js_yaml_1.default, 'load');
|
|
410
|
+
try {
|
|
411
|
+
expect((0, model_1.parseProcessFrontmatter)(source, 'p')).toEqual({
|
|
412
|
+
diagnostics: ['p: invalid process model — frontmatter keys must not be indented (frontmatter line 1)'],
|
|
413
|
+
});
|
|
414
|
+
expect(load).not.toHaveBeenCalled();
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
load.mockRestore();
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
});
|
|
83
421
|
});
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
exports.symlinkTest = exports.canCreateSymlinks = exports.posixOnly = void 0;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const os_1 = __importDefault(require("os"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
/** `test` where symlinks and POSIX modes exist; reported as skipped (not as passed) on Windows. */
|
|
11
|
+
exports.posixOnly = process.platform === 'win32' ? test.skip : test;
|
|
12
|
+
/** True when this process can create a symlink (Windows needs the privilege or developer mode). */
|
|
13
|
+
exports.canCreateSymlinks = (() => {
|
|
14
|
+
const dir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-symlink-probe-'));
|
|
15
|
+
try {
|
|
16
|
+
fs_1.default.symlinkSync(path_1.default.join(dir, 'missing-target'), path_1.default.join(dir, 'link'));
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
26
|
+
/** `test` that needs to create symlinks; reported as skipped (not as passed) where it cannot. */
|
|
27
|
+
exports.symlinkTest = exports.canCreateSymlinks ? test : test.skip;
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// stdin, bounds time and output and sets GIT_NO_LAZY_FETCH. A bare `execFileSync('git', ...)`
|
|
9
9
|
// in these directories silently loses all of that, and reviewers found such leaks four times.
|
|
10
10
|
// 2. A test that returns early on Windows is reported as PASSED by the 3-platform release gate.
|
|
11
|
-
//
|
|
11
|
+
// Every suite under cli/tests uses a visible skip instead: `posixOnly`, or `symlinkTest` when the
|
|
12
|
+
// test only needs symlinks (cli/tests/helpers/platform.ts).
|
|
12
13
|
//
|
|
13
14
|
// The detectors are exercised on inline samples below, so each guard demonstrably fires.
|
|
14
15
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
@@ -66,10 +67,10 @@ describe('change-record conventions (structural)', () => {
|
|
|
66
67
|
.map(file => path_1.default.relative(CLI_ROOT, file));
|
|
67
68
|
expect(offenders).toEqual([]);
|
|
68
69
|
});
|
|
69
|
-
test('
|
|
70
|
-
|
|
71
|
-
const offenders =
|
|
72
|
-
.
|
|
70
|
+
test('no test under cli/tests returns early on Windows instead of skipping visibly', () => {
|
|
71
|
+
// This file is excluded: its detector samples are offending shapes on purpose.
|
|
72
|
+
const offenders = tsFiles(path_1.default.join(CLI_ROOT, 'tests'))
|
|
73
|
+
.filter(file => file !== __filename)
|
|
73
74
|
.filter(file => silentWin32Returns(fs_1.default.readFileSync(file, 'utf8')))
|
|
74
75
|
.map(file => path_1.default.relative(CLI_ROOT, file));
|
|
75
76
|
expect(offenders).toEqual([]);
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
const fs_1 = __importDefault(require("fs"));
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const DESIGN = path_1.default.resolve(__dirname, '../../../docs/plans/2026-08-23-process-lifecycle-design.md');
|
|
9
|
+
function artifactSection() {
|
|
10
|
+
const text = fs_1.default.readFileSync(DESIGN, 'utf8');
|
|
11
|
+
const start = text.indexOf('### El artefacto');
|
|
12
|
+
expect(start).toBeGreaterThan(-1);
|
|
13
|
+
const end = text.indexOf('\n### ', start + 1);
|
|
14
|
+
return text.slice(start, end === -1 ? undefined : end);
|
|
15
|
+
}
|
|
16
|
+
describe('process-model contract documents the optional description (PD-4)', () => {
|
|
17
|
+
it('lists description in the frontmatter block as optional', () => {
|
|
18
|
+
const section = artifactSection();
|
|
19
|
+
expect(section).toMatch(/^description: <texto de una línea> {2}# opcional$/m);
|
|
20
|
+
});
|
|
21
|
+
it('states the rules the parser enforces and why the field exists', () => {
|
|
22
|
+
/** Whitespace-normalized so re-wrapping the prose never breaks a pinned phrase. */
|
|
23
|
+
const section = artifactSection().replace(/\s+/g, ' ');
|
|
24
|
+
expect(section).toContain('#### Enmienda 2026-10-18 — `description` opcional');
|
|
25
|
+
expect(section).toContain('una sola línea');
|
|
26
|
+
expect(section).toContain('1024 caracteres');
|
|
27
|
+
expect(section).toContain('Codex');
|
|
28
|
+
expect(section).toContain('único campo opcional');
|
|
29
|
+
expect(section).toContain('parser YAML');
|
|
30
|
+
expect(section).not.toContain('no reemplaza a un parser YAML');
|
|
31
|
+
expect(section).toContain('code points');
|
|
32
|
+
expect(section).toContain('caracteres de control');
|
|
33
|
+
});
|
|
34
|
+
});
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|