agentic-workflow-manager 6.2.0 → 6.3.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/README.md +17 -0
- package/dist/src/commands/job/gate.js +37 -13
- package/dist/src/commands/job/index.js +48 -8
- package/dist/src/commands/job/request.js +44 -6
- package/dist/src/commands/track/emit.js +26 -0
- package/dist/src/commands/track/index.js +194 -0
- package/dist/src/commands/track/status.js +63 -0
- package/dist/src/commands/track/supervisor-wrapper.js +89 -0
- package/dist/src/commands/watch/apply.js +201 -13
- package/dist/src/commands/watch/index.js +14 -0
- package/dist/src/commands/watch/runner.js +20 -1
- package/dist/src/commands/watch/supervisor.js +251 -27
- package/dist/src/commands/watch/teardown-driver.js +189 -0
- package/dist/src/commands/watch/tracks.js +1100 -0
- package/dist/src/core/diagnostics/provider-checks.js +12 -9
- package/dist/src/core/journal/adapter.js +46 -6
- package/dist/src/core/journal/paths.js +9 -0
- package/dist/src/core/journal/process.js +101 -1
- package/dist/src/core/journal/requests.js +5 -1
- package/dist/src/core/journal/store.js +34 -2
- package/dist/src/core/journal/types.js +93 -4
- package/dist/src/core/paths.js +51 -0
- package/dist/src/core/profile.js +15 -1
- package/dist/src/core/tracks/concurrency.js +85 -0
- package/dist/src/core/tracks/context.js +89 -0
- package/dist/src/core/tracks/descriptor.js +40 -0
- package/dist/src/core/tracks/git.js +318 -0
- package/dist/src/core/tracks/join.js +185 -0
- package/dist/src/core/tracks/ownership.js +100 -0
- package/dist/src/core/tracks/plan-parser.js +108 -0
- package/dist/src/core/tracks/protocol.js +466 -0
- package/dist/src/core/tracks/teardown.js +34 -0
- package/dist/src/core/tracks/types.js +14 -0
- package/dist/src/core/update-check.js +5 -1
- package/dist/src/index.js +2 -0
- package/dist/tests/commands/job/gate-reconcile.test.js +267 -0
- package/dist/tests/commands/track/fixtures.js +13 -0
- package/dist/tests/commands/track/status.test.js +157 -0
- package/dist/tests/commands/track/supervisor-wrapper-cli.test.js +129 -0
- package/dist/tests/commands/track/supervisor-wrapper.test.js +131 -0
- package/dist/tests/commands/track/verbs.test.js +326 -0
- package/dist/tests/commands/watch/apply.test.js +116 -4
- package/dist/tests/commands/watch/runner.test.js +22 -0
- package/dist/tests/commands/watch/supervisor-loop.test.js +150 -0
- package/dist/tests/commands/watch/track-bootstrap-crash.test.js +342 -0
- package/dist/tests/commands/watch/track-bootstrap.test.js +350 -0
- package/dist/tests/commands/watch/track-finalize.test.js +643 -0
- package/dist/tests/commands/watch/track-freeze.test.js +591 -0
- package/dist/tests/commands/watch/track-join-crash.test.js +439 -0
- package/dist/tests/commands/watch/track-runtime-git.test.js +119 -0
- package/dist/tests/commands/watch/track-teardown-crash.test.js +426 -0
- package/dist/tests/core/diagnostics/provider-tier.test.js +3 -3
- package/dist/tests/core/journal/adapter-override.test.js +51 -0
- package/dist/tests/core/journal/process.test.js +37 -0
- package/dist/tests/core/journal/requests.test.js +21 -0
- package/dist/tests/core/journal/store.test.js +28 -0
- package/dist/tests/core/journal/types.test.js +89 -0
- package/dist/tests/core/profile.test.js +9 -2
- package/dist/tests/core/same-existing-path.test.js +48 -0
- package/dist/tests/core/tracks/concurrency.test.js +134 -0
- package/dist/tests/core/tracks/context.test.js +177 -0
- package/dist/tests/core/tracks/descriptor.test.js +76 -0
- package/dist/tests/core/tracks/git.test.js +132 -0
- package/dist/tests/core/tracks/join-reconcile.test.js +53 -0
- package/dist/tests/core/tracks/join.test.js +197 -0
- package/dist/tests/core/tracks/ownership.test.js +96 -0
- package/dist/tests/core/tracks/plan-parser.test.js +94 -0
- package/dist/tests/core/tracks/protocol.test.js +416 -0
- package/dist/tests/core/tracks/teardown.test.js +62 -0
- package/dist/tests/core/update-check.test.js +34 -3
- package/dist/tests/helpers/git-fixture.js +35 -0
- package/dist/tests/integration/parallel-tracks.e2e.test.js +343 -0
- package/dist/tests/integration/r5-provider-evidence.test.js +67 -0
- package/dist/tests/structural/path-identity-not-string-compare.test.js +51 -0
- package/package.json +1 -1
|
@@ -0,0 +1,343 @@
|
|
|
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
|
+
// R5 Task 15 — E2E de aceptación de los tracks paralelos (CA-4.1–CA-4.3).
|
|
7
|
+
//
|
|
8
|
+
// **Los snippets del plan estaban escritos contra una API imaginada.** Task 15 esperaba
|
|
9
|
+
// `runWorkload()` devolviendo `{ cycleStatus, treeHash, events: [{ kind: 'parallel-degraded' }] }`
|
|
10
|
+
// y un seam llamado `worktreeAdder`. Nada de eso existe: la implementación shippeó
|
|
11
|
+
// `reconcileTracks` devolviendo `{ state, effectExecuted }`, la degradación como
|
|
12
|
+
// `fallbackReason` en el journal, y el seam como `TrackRuntime.addWorktree`.
|
|
13
|
+
//
|
|
14
|
+
// Es la CUARTA vez en este repo que una especificación se escribe contra lo que se
|
|
15
|
+
// esperaba y no contra lo que hay (las otras: `--type` en `add`, `AG-03`, `CORE-17`). Se
|
|
16
|
+
// resuelve igual que las anteriores: los tests se escriben contra el contrato REAL, y la
|
|
17
|
+
// discrepancia se deja anotada — forzar la implementación a parecerse a un plan escrito
|
|
18
|
+
// antes que ella sería cambiar código que funciona para satisfacer una suposición.
|
|
19
|
+
//
|
|
20
|
+
// Lo que NO cambia es qué se verifica: CA-4.1, CA-4.2 y CA-4.3 siguen siendo los criterios.
|
|
21
|
+
const fs_1 = __importDefault(require("fs"));
|
|
22
|
+
const os_1 = __importDefault(require("os"));
|
|
23
|
+
const path_1 = __importDefault(require("path"));
|
|
24
|
+
const child_process_1 = require("child_process");
|
|
25
|
+
const plan_parser_1 = require("../../src/core/tracks/plan-parser");
|
|
26
|
+
const ownership_1 = require("../../src/core/tracks/ownership");
|
|
27
|
+
const tracks_1 = require("../../src/commands/watch/tracks");
|
|
28
|
+
const store_1 = require("../../src/core/journal/store");
|
|
29
|
+
const process_1 = require("../../src/core/journal/process");
|
|
30
|
+
const WORKLOAD = path_1.default.join(__dirname, '..', 'fixtures', 'tracks', 'workload');
|
|
31
|
+
function readPlan() {
|
|
32
|
+
return fs_1.default.readFileSync(path_1.default.join(WORKLOAD, 'plan.md'), 'utf8');
|
|
33
|
+
}
|
|
34
|
+
/** El fixture DEBE parsear como candidato a paralelo — si degradara a `serial`, todos los
|
|
35
|
+
* criterios de abajo estarían midiendo otra cosa. Narrow explícito para que un cambio en
|
|
36
|
+
* el fixture falle acá, con nombre, y no en un `undefined` diez líneas después. */
|
|
37
|
+
function parsePlan() {
|
|
38
|
+
const parsed = (0, plan_parser_1.parseTrackPlan)(readPlan(), () => true);
|
|
39
|
+
if (parsed.mode !== 'parallel-candidate') {
|
|
40
|
+
throw new Error(`el fixture del workload degradó a serial: ${parsed.reason}`);
|
|
41
|
+
}
|
|
42
|
+
return { tracks: Object.values(parsed.tracks), argv: parsed.integration.argv, paths: parsed.integration.paths };
|
|
43
|
+
}
|
|
44
|
+
/** Devuelve los tracks con el ownership de `trackId` reemplazado — para probar el efecto de
|
|
45
|
+
* UNA declaración sobre el veredicto de TODA la cohorte sin tocar el fixture en disco. */
|
|
46
|
+
function withOwnership(trackId, ownership) {
|
|
47
|
+
return parsePlan().tracks.map((t) => (t.trackId === trackId ? { ...t, ownership } : t));
|
|
48
|
+
}
|
|
49
|
+
/** Todo tmpdir creado por esta suite, para que el `afterEach` remueva SOLO lo propio incluso
|
|
50
|
+
* cuando un `expect` falla antes del cleanup inline (Step 7: la corrida N+1 no puede
|
|
51
|
+
* heredar basura de la N). El borrado es idempotente: `force: true` sobre algo ya removido
|
|
52
|
+
* es un no-op. */
|
|
53
|
+
const created = [];
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
while (created.length > 0) {
|
|
56
|
+
const dir = created.pop();
|
|
57
|
+
// `git worktree` deja el repo principal con referencias a worktrees ya borrados; el
|
|
58
|
+
// prune evita que un `git worktree list` posterior las reporte como vivas.
|
|
59
|
+
try {
|
|
60
|
+
(0, child_process_1.execFileSync)('git', ['worktree', 'prune'], { cwd: dir, stdio: 'pipe' });
|
|
61
|
+
}
|
|
62
|
+
catch { /* no es un repo, o ya no existe */ }
|
|
63
|
+
fs_1.default.rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
/** Registra un tmpdir propio para el `afterEach` y lo devuelve. */
|
|
67
|
+
function track(dir) {
|
|
68
|
+
created.push(dir);
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
/** Repo con el estado inicial del workload y git utilizable sin config global.
|
|
72
|
+
* El `.gitignore` con `.awm/` no es decorativo: `defaultTrackRuntime.addWorktree` descarta
|
|
73
|
+
* el worktree recién creado si no puede PROBAR que `.awm` está ignorado (degradación C2), y
|
|
74
|
+
* además es lo que hace que el árbol del camino serial y el del paralelo sean comparables —
|
|
75
|
+
* el journal del track nunca entra al árbol versionado. */
|
|
76
|
+
function seedRepo() {
|
|
77
|
+
const repo = track(fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-r5-e2e-'))));
|
|
78
|
+
const git = (...args) => (0, child_process_1.execFileSync)('git', args, { cwd: repo, stdio: 'pipe' });
|
|
79
|
+
git('init', '-q', '-b', 'main');
|
|
80
|
+
git('config', 'user.email', 'e2e@awm.test');
|
|
81
|
+
git('config', 'user.name', 'AWM E2E');
|
|
82
|
+
git('config', 'commit.gpgsign', 'false');
|
|
83
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, '.gitignore'), '.awm/\n');
|
|
84
|
+
fs_1.default.mkdirSync(path_1.default.join(repo, 'src'), { recursive: true });
|
|
85
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'src', 'a.txt'), 'A-inicial\n');
|
|
86
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'src', 'b.txt'), 'B-inicial\n');
|
|
87
|
+
git('add', '-A');
|
|
88
|
+
git('commit', '-q', '-m', 'seed');
|
|
89
|
+
return repo;
|
|
90
|
+
}
|
|
91
|
+
/** Corre el trabajo de un track con el mismo `apply-task.mjs` que usaría el track real:
|
|
92
|
+
* argv estructurado, sin shell. */
|
|
93
|
+
function applyTask(repo, file, value) {
|
|
94
|
+
(0, child_process_1.execFileSync)(process.execPath, [path_1.default.join(WORKLOAD, 'apply-task.mjs'), path_1.default.join(repo, file), value], { stdio: 'pipe' });
|
|
95
|
+
}
|
|
96
|
+
/** El comando canónico de integración del plan, ejecutado como el supervisor lo haría:
|
|
97
|
+
* argv del plan, cwd = repo, sin shell. Devuelve el exit code. */
|
|
98
|
+
function runIntegration(repo) {
|
|
99
|
+
const { argv } = parsePlan();
|
|
100
|
+
const [command, ...args] = argv;
|
|
101
|
+
// El argv del plan referencia el verificador por ruta relativa al repo del CLI; en el
|
|
102
|
+
// repo temporal se resuelve contra el fixture real.
|
|
103
|
+
const resolved = args.map((a) => (a.endsWith('verify.mjs') ? path_1.default.join(WORKLOAD, 'verify.mjs') : a));
|
|
104
|
+
// `node` del plan se resuelve al ejecutable de esta corrida: en Windows CI el `node` del
|
|
105
|
+
// PATH puede no existir bajo el mismo nombre, y un ENOENT ahí sería un falso rojo del
|
|
106
|
+
// criterio, no una integración fallida.
|
|
107
|
+
const bin = command === 'node' ? process.execPath : command;
|
|
108
|
+
try {
|
|
109
|
+
(0, child_process_1.execFileSync)(bin, resolved, { cwd: repo, stdio: 'pipe' });
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
return e.status ?? 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function treeHashOf(repo) {
|
|
117
|
+
return (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD^{tree}'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
118
|
+
}
|
|
119
|
+
function headOf(repo) {
|
|
120
|
+
return (0, child_process_1.execFileSync)('git', ['rev-parse', 'HEAD'], { cwd: repo, encoding: 'utf8' }).trim();
|
|
121
|
+
}
|
|
122
|
+
describe('R5 · el plan del workload declara dos tracks realmente independientes', () => {
|
|
123
|
+
it('parsea el argv de integración como ARRAY, no como prosa (C4)', () => {
|
|
124
|
+
const parsed = parsePlan();
|
|
125
|
+
// Que sea un array es la propiedad, no un detalle: prosa libre no puede
|
|
126
|
+
// seleccionar el comando que se ejecuta sobre el HEAD final.
|
|
127
|
+
expect(Array.isArray(parsed.argv)).toBe(true);
|
|
128
|
+
expect(parsed.argv[0]).toBe('node');
|
|
129
|
+
expect(parsed.paths).toEqual(['src/**']);
|
|
130
|
+
expect(parsed.tracks.map((t) => t.trackId).sort()).toEqual(['a', 'b']);
|
|
131
|
+
});
|
|
132
|
+
it('los dos tracks pasan la evaluación de independencia declarada', () => {
|
|
133
|
+
const { tracks } = parsePlan();
|
|
134
|
+
expect((0, ownership_1.assessDeclaredIndependence)(tracks)).toEqual({ parallel: true, reasons: [] });
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// CA-4.1 — serial y paralelo producen el MISMO ÁRBOL
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
//
|
|
141
|
+
// Sobre tree hash y NO sobre historial, a proposito: integrar A→B y B→A produce commits
|
|
142
|
+
// distintos y el mismo contenido. Comparar el historial daria un rojo que no significa
|
|
143
|
+
// nada, y es el error que este criterio existe para no cometer.
|
|
144
|
+
/** Integra los dos tracks en el orden pedido y devuelve árbol, commit y exit del comando
|
|
145
|
+
* canónico. Cada track trabaja en su propia rama, como en la ejecución real. */
|
|
146
|
+
function integrate(order) {
|
|
147
|
+
const repo = seedRepo();
|
|
148
|
+
const git = (...args) => (0, child_process_1.execFileSync)('git', args, { cwd: repo, stdio: 'pipe' });
|
|
149
|
+
for (const track of order) {
|
|
150
|
+
git('checkout', '-q', '-b', `awm-track/${track}`, 'main');
|
|
151
|
+
applyTask(repo, `src/${track}.txt`, `${track.toUpperCase()}-final`);
|
|
152
|
+
git('add', '-A');
|
|
153
|
+
git('commit', '-q', '-m', `track ${track}`);
|
|
154
|
+
git('checkout', '-q', 'main');
|
|
155
|
+
}
|
|
156
|
+
for (const track of order)
|
|
157
|
+
git('merge', '-q', '--no-ff', '-m', `join ${track}`, `awm-track/${track}`);
|
|
158
|
+
return { tree: treeHashOf(repo), commit: headOf(repo), verify: runIntegration(repo) };
|
|
159
|
+
}
|
|
160
|
+
/** El árbol que produce el camino PARALELO — la referencia contra la que CA-4.2 compara su
|
|
161
|
+
* camino degradado. Se recalcula en vez de compartir estado entre tests: un `let` global
|
|
162
|
+
* haría que el orden de ejecución de los describes decidiera si CA-4.2 tiene contra qué
|
|
163
|
+
* comparar. */
|
|
164
|
+
const parallelTreeHash = () => integrate(['a', 'b']).tree;
|
|
165
|
+
describe('CA-4.1 · el orden de integración no cambia el resultado', () => {
|
|
166
|
+
it('dos órdenes de integración distintos dan el mismo árbol y ambos verifican', () => {
|
|
167
|
+
const ab = integrate(['a', 'b']);
|
|
168
|
+
const ba = integrate(['b', 'a']);
|
|
169
|
+
expect(ab.tree).toBe(ba.tree);
|
|
170
|
+
// Los commits SÍ difieren — si coincidieran, el test no estaría probando nada
|
|
171
|
+
// sobre el orden, estaría comparando la misma corrida consigo misma.
|
|
172
|
+
expect(ab.commit).not.toBe(ba.commit);
|
|
173
|
+
// Y el comando canónico acepta los dos: el trabajo de ambos tracks sobrevivió.
|
|
174
|
+
expect(ab.verify).toBe(0);
|
|
175
|
+
expect(ba.verify).toBe(0);
|
|
176
|
+
});
|
|
177
|
+
it('el verificador canónico REPRUEBA si se pierde el trabajo de un track', () => {
|
|
178
|
+
// Sin esto, el test de arriba pasaría igual con un verificador que siempre
|
|
179
|
+
// devuelve 0 — y no probaría que la integración vio los dos tracks.
|
|
180
|
+
const repo = seedRepo();
|
|
181
|
+
applyTask(repo, 'src/a.txt', 'A-final');
|
|
182
|
+
expect(runIntegration(repo)).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// CA-4.2 — un worktree que falla degrada a serial, y el serial DA LO MISMO
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
//
|
|
189
|
+
// El snippet del plan inyectaba un `worktreeAdder`; el seam real es
|
|
190
|
+
// `TrackRuntime.addWorktree`, y la degradación se observa como `cohortPhase: 'SERIAL'` +
|
|
191
|
+
// `fallbackReason`, no como un evento `parallel-degraded`.
|
|
192
|
+
//
|
|
193
|
+
// `track-bootstrap-crash.test.ts` ya prueba que el fallback desmantela los recursos reales
|
|
194
|
+
// y no deja worktrees vivos. Lo que NO estaba probado en ningún lado — y es la mitad de
|
|
195
|
+
// CA-4.2 que importa — es que el camino degradado no solo **termina**, sino que **termina
|
|
196
|
+
// con el mismo árbol** que el paralelo. Un fallback que completa produciendo otra cosa es
|
|
197
|
+
// peor que uno que bloquea: pasa desapercibido.
|
|
198
|
+
describe('CA-4.2 · un addWorktree que falla degrada a serial sin cambiar el resultado', () => {
|
|
199
|
+
const BRANCH = 'main';
|
|
200
|
+
/** Cohorte de dos tracks en `DECLARED`, escrita directo al journal — mismo patrón que
|
|
201
|
+
* `track-bootstrap-crash.test.ts`: `awm track` real no participa acá, lo que se
|
|
202
|
+
* ejercita es el reducer + los efectos reales de git. */
|
|
203
|
+
function declareCohort(planRoot, worktreeRoot, baseSha) {
|
|
204
|
+
(0, store_1.initJournal)(planRoot, BRANCH);
|
|
205
|
+
const s = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
206
|
+
s.cohortPhase = 'PREPARING';
|
|
207
|
+
s.cohortBaseSha = baseSha;
|
|
208
|
+
s.tracks = ['a', 'b'].map((id) => ({
|
|
209
|
+
trackId: id,
|
|
210
|
+
worktreePath: path_1.default.join(worktreeRoot, `track-${id}`),
|
|
211
|
+
branch: `awm-track/${id}`,
|
|
212
|
+
ownership: [`src/${id}.txt`], sharedResources: [], dependsOn: [],
|
|
213
|
+
fencingToken: `fence-${id}`.padEnd(32, '0'),
|
|
214
|
+
phase: 'DECLARED',
|
|
215
|
+
readinessNonce: `ready-${id}`.padEnd(32, '0'),
|
|
216
|
+
}));
|
|
217
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s);
|
|
218
|
+
return (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
219
|
+
}
|
|
220
|
+
/** git REAL para worktree/branch (única forma honesta de probar que no queda nada
|
|
221
|
+
* vivo); supervisor fake — acá no se ejercita el spawn de procesos, eso ya vive en
|
|
222
|
+
* `supervisor-wrapper.test.ts`. `addWorktree` falla para `failFor`. */
|
|
223
|
+
function buildRuntime(planRoot, failFor) {
|
|
224
|
+
const real = (0, tracks_1.defaultTrackRuntime)(planRoot, BRANCH);
|
|
225
|
+
const nunca = (name) => () => { throw new Error(`${name} no debería llamarse en el camino de fallback`); };
|
|
226
|
+
return {
|
|
227
|
+
addWorktree(root, ref, baseSha) {
|
|
228
|
+
if (ref.trackId === failFor)
|
|
229
|
+
throw new Error(`fallo inyectado: create-worktree de ${ref.trackId}`);
|
|
230
|
+
real.addWorktree(root, ref, baseSha);
|
|
231
|
+
},
|
|
232
|
+
initTrackJournal(ref, context) { real.initTrackJournal(ref, context); },
|
|
233
|
+
spawnSupervisor(ref) { return (0, process_1.captureSelfRef)(ref.readinessNonce); },
|
|
234
|
+
observeSupervisor(ref) { return { kind: 'ready', readinessNonce: ref.readinessNonce }; },
|
|
235
|
+
async stopOwnSupervisor() { return true; },
|
|
236
|
+
removeOwnedWorktree(repo, ref) { real.removeOwnedWorktree(repo, ref); },
|
|
237
|
+
removeOwnedBranch(repo, branch) { real.removeOwnedBranch(repo, branch); },
|
|
238
|
+
emitFreezeRequest: nunca('emitFreezeRequest'),
|
|
239
|
+
mergeFrozenTrack: nunca('mergeFrozenTrack'),
|
|
240
|
+
abortOwnedMerge: nunca('abortOwnedMerge'),
|
|
241
|
+
ensureIntegrationLock: nunca('ensureIntegrationLock'),
|
|
242
|
+
pauseControllerGeneration: nunca('pauseControllerGeneration'),
|
|
243
|
+
releaseIntegrationLockIfHeld: nunca('releaseIntegrationLockIfHeld'),
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
/** El árbol que produce el camino serial: las dos tasks aplicadas una tras otra sobre
|
|
247
|
+
* `main`, sin worktrees, sin merges. */
|
|
248
|
+
function serialTree(repo) {
|
|
249
|
+
const git = (...args) => (0, child_process_1.execFileSync)('git', args, { cwd: repo, stdio: 'pipe' });
|
|
250
|
+
for (const track of ['a', 'b']) {
|
|
251
|
+
applyTask(repo, `src/${track}.txt`, `${track.toUpperCase()}-final`);
|
|
252
|
+
git('add', '-A');
|
|
253
|
+
git('commit', '-q', '-m', `serial ${track}`);
|
|
254
|
+
}
|
|
255
|
+
return { tree: treeHashOf(repo), verify: runIntegration(repo) };
|
|
256
|
+
}
|
|
257
|
+
it('degrada a SERIAL nombrando la causa, no deja worktrees, y produce el mismo árbol', async () => {
|
|
258
|
+
const planRoot = seedRepo();
|
|
259
|
+
const worktreeRoot = track(fs_1.default.realpathSync(fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-r5-wt-'))));
|
|
260
|
+
const baseSha = headOf(planRoot);
|
|
261
|
+
const runtime = buildRuntime(planRoot, 'b');
|
|
262
|
+
let s = declareCohort(planRoot, worktreeRoot, baseSha);
|
|
263
|
+
for (let i = 0; i < 200 && s.cohortPhase !== 'SERIAL'; i++) {
|
|
264
|
+
s = (await (0, tracks_1.reconcileTracks)(planRoot, BRANCH, s, runtime, 2)).state;
|
|
265
|
+
}
|
|
266
|
+
expect(s.cohortPhase).toBe('SERIAL');
|
|
267
|
+
// La causa queda NOMBRADA y sobrevive a la reconstrucción del protocolo en cada
|
|
268
|
+
// vuelta del loop — un fallback anónimo no es auditable.
|
|
269
|
+
expect(s.cohortFallbackReason).toBe('effect-failed:create-worktree:b');
|
|
270
|
+
// Y se lee en el evento de la degradación misma, no correlacionando hacia atrás.
|
|
271
|
+
const events = fs_1.default.readFileSync(path_1.default.join(planRoot, '.awm', 'journal', BRANCH, 'events.jsonl'), 'utf8')
|
|
272
|
+
.split('\n').filter((l) => l.length > 0).map((l) => JSON.parse(l));
|
|
273
|
+
expect(events).toContainEqual(expect.objectContaining({ effect: 'enter-serial', reason: 'effect-failed:create-worktree:b' }));
|
|
274
|
+
// El evento que la originó sigue nombrando el fallo inyectado (CA-4.2 del plan).
|
|
275
|
+
expect(events).toContainEqual(expect.objectContaining({
|
|
276
|
+
kind: 'track-effect-failed', trackId: 'b', detail: expect.stringContaining('inyectado'),
|
|
277
|
+
}));
|
|
278
|
+
// Prueba real contra git, no contra eventos: el track 'a' — que sí llegó a crear su
|
|
279
|
+
// worktree antes de que 'b' fallara — quedó desmantelado.
|
|
280
|
+
const worktrees = (0, child_process_1.execFileSync)('git', ['worktree', 'list', '--porcelain'], { cwd: planRoot, encoding: 'utf8' });
|
|
281
|
+
expect(worktrees).not.toContain('awm-track/a');
|
|
282
|
+
expect(fs_1.default.existsSync(path_1.default.join(worktreeRoot, 'track-a'))).toBe(false);
|
|
283
|
+
expect((0, child_process_1.execFileSync)('git', ['branch', '--list'], { cwd: planRoot, encoding: 'utf8' })).not.toContain('awm-track/');
|
|
284
|
+
// Y ahora la mitad que nadie más cubre: el serial produce EL MISMO árbol que el
|
|
285
|
+
// paralelo de CA-4.1, y el comando canónico lo acepta igual.
|
|
286
|
+
const serial = serialTree(planRoot);
|
|
287
|
+
expect(serial.verify).toBe(0);
|
|
288
|
+
expect(serial.tree).toBe(parallelTreeHash());
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
// ---------------------------------------------------------------------------
|
|
292
|
+
// CA-4.3 — una clase global invalida el paralelismo de TODA la cohorte (C5)
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
describe('CA-4.3 · tocar una clase global invalida la cohorte entera', () => {
|
|
295
|
+
it('un solo track que declara package-lock.json apaga el paralelismo', () => {
|
|
296
|
+
const trackA = parsePlan().tracks.find((t) => t.trackId === 'a');
|
|
297
|
+
const conLockfile = withOwnership('a', [...trackA.ownership, 'package-lock.json']);
|
|
298
|
+
const verdict = (0, ownership_1.assessDeclaredIndependence)(conLockfile);
|
|
299
|
+
expect(verdict.parallel).toBe(false);
|
|
300
|
+
expect(verdict.reasons).toContain('global:lockfile:package-lock.json');
|
|
301
|
+
});
|
|
302
|
+
it.each([
|
|
303
|
+
['package-lock.json', 'global:lockfile:package-lock.json'],
|
|
304
|
+
['pnpm-lock.yaml', 'global:lockfile:pnpm-lock.yaml'],
|
|
305
|
+
['package.json', 'global:manifest:package.json'],
|
|
306
|
+
['migrations/001.sql', 'global:migration:migrations/001.sql'],
|
|
307
|
+
])('%s se clasifica como global y nombra su clase', (file, reason) => {
|
|
308
|
+
// Fail-closed por CLASE, no por archivo: la regla es que el paralelismo se apaga
|
|
309
|
+
// ante lockfiles/manifests/migraciones, y el mensaje dice CUÁL para que la
|
|
310
|
+
// decisión sea auditable en vez de un "no se puede" pelado.
|
|
311
|
+
expect((0, ownership_1.assessDeclaredIndependence)(withOwnership('a', [file])).reasons).toContain(reason);
|
|
312
|
+
});
|
|
313
|
+
it('la evaluación es de la COHORTE: basta que UN track lo toque', () => {
|
|
314
|
+
// C5 en una línea. Si esto se relajara a "solo el track que lo toca va serial",
|
|
315
|
+
// dos tracks podrían reescribir el mismo lockfile en paralelo.
|
|
316
|
+
expect((0, ownership_1.assessDeclaredIndependence)(withOwnership('b', ['yarn.lock'])).parallel).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Ownership REAL violado después de ejecutar (Task 15 Step 5)
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
describe('ownership real · lo declarado se contrasta contra lo que el track tocó', () => {
|
|
323
|
+
it('un track que toca fuera de su ownership queda nombrado, archivo por archivo', () => {
|
|
324
|
+
const trackA = parsePlan().tracks.find((t) => t.trackId === 'a');
|
|
325
|
+
const assessment = (0, ownership_1.assessActualOwnership)(trackA, [
|
|
326
|
+
{ status: 'M', path: 'src/a.txt' },
|
|
327
|
+
{ status: 'M', path: 'outside.txt' },
|
|
328
|
+
]);
|
|
329
|
+
expect(assessment.outsideOwnership).toEqual(['outside.txt']);
|
|
330
|
+
// No es una violación de clase global: es trabajo fuera de lo declarado, y se
|
|
331
|
+
// reporta distinto porque el remedio es distinto.
|
|
332
|
+
expect(assessment.globalClasses).toEqual([]);
|
|
333
|
+
});
|
|
334
|
+
it('un rename cuenta las DOS puntas — el origen también es trabajo hecho', () => {
|
|
335
|
+
const trackA = parsePlan().tracks.find((t) => t.trackId === 'a');
|
|
336
|
+
const assessment = (0, ownership_1.assessActualOwnership)(trackA, [
|
|
337
|
+
{ status: 'R100', path: 'src/a.txt', oldPath: 'legacy/a.txt' },
|
|
338
|
+
]);
|
|
339
|
+
// Mirar solo el destino diría que el track se portó bien, cuando en realidad
|
|
340
|
+
// borró un archivo que no le pertenecía.
|
|
341
|
+
expect(assessment.outsideOwnership).toEqual(['legacy/a.txt']);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
// R5 Task 16 — gate de INTEGRIDAD de la evidencia, no gate de "todo certificado".
|
|
7
|
+
//
|
|
8
|
+
// La primera versión de este test exigía evidencia `pass` de dos providers reales, así que
|
|
9
|
+
// fallaba siempre. Un test rojo permanente no es un gate: enseña a ignorar el rojo, y el
|
|
10
|
+
// primero que necesite mergear lo borra. Lo que este archivo vigila ahora es lo único que
|
|
11
|
+
// un test PUEDE vigilar acá: que nadie convierta una ausencia en un `supported`.
|
|
12
|
+
//
|
|
13
|
+
// El estado real de la certificación vive en `docs/research/r5/README.md` y en la matriz
|
|
14
|
+
// generada — no en si esta suite está verde.
|
|
15
|
+
const fs_1 = __importDefault(require("fs"));
|
|
16
|
+
const path_1 = __importDefault(require("path"));
|
|
17
|
+
const RESEARCH = path_1.default.resolve(__dirname, '../../../docs/research/r5');
|
|
18
|
+
const EVIDENCE = path_1.default.join(RESEARCH, 'evidence');
|
|
19
|
+
const MATRIX = path_1.default.join(RESEARCH, 'provider-matrix.md');
|
|
20
|
+
const evidenceFiles = () => (fs_1.default.existsSync(EVIDENCE) ? fs_1.default.readdirSync(EVIDENCE) : []).filter((f) => f.endsWith('.json'));
|
|
21
|
+
describe('R5 · integridad de la evidencia de providers', () => {
|
|
22
|
+
it('la matriz existe y se generó desde la evidencia', () => {
|
|
23
|
+
expect(fs_1.default.existsSync(MATRIX)).toBe(true);
|
|
24
|
+
expect(fs_1.default.readFileSync(MATRIX, 'utf8')).toContain('GENERADO por provider-run.mjs');
|
|
25
|
+
});
|
|
26
|
+
it('ninguna capability dice `supported` sin un JSON de evidencia que la respalde', () => {
|
|
27
|
+
const text = fs_1.default.readFileSync(MATRIX, 'utf8');
|
|
28
|
+
// Si la matriz afirma soporte en algún lado, tiene que haber al menos una evidencia
|
|
29
|
+
// en disco. Sin esto, un `supported` escrito a mano pasaría inadvertido para siempre.
|
|
30
|
+
if (text.includes('supported'))
|
|
31
|
+
expect(evidenceFiles().length).toBeGreaterThan(0);
|
|
32
|
+
});
|
|
33
|
+
it.each(evidenceFiles())('%s es evidencia bien formada y sanitizada', (name) => {
|
|
34
|
+
const x = JSON.parse(fs_1.default.readFileSync(path_1.default.join(EVIDENCE, name), 'utf8'));
|
|
35
|
+
expect(x.schema).toBe(1);
|
|
36
|
+
expect(`${x.provider}-${x.environment}.json`).toBe(name);
|
|
37
|
+
expect(['pass', 'partial', 'fail']).toContain(x.result);
|
|
38
|
+
// `pass` es la única palabra que autoriza a la matriz a decir `supported`, así que es
|
|
39
|
+
// la que más barato sale falsificar: exige los tres ejercicios en verde.
|
|
40
|
+
if (x.result === 'pass') {
|
|
41
|
+
expect(x.exercises).toMatchObject({ bootstrap: 'pass', recovery: 'pass', join: 'pass', finalIntegrationRuns: 1 });
|
|
42
|
+
}
|
|
43
|
+
expect(x.sourceHead).toMatch(/^[0-9a-f]{40}$/);
|
|
44
|
+
expect(x.commands).toEqual(expect.arrayContaining([expect.stringContaining('node dist/src/index.js')]));
|
|
45
|
+
// Los artefactos referenciados existen: una lista de rutas inventadas certificaría
|
|
46
|
+
// una corrida que nadie puede volver a mirar.
|
|
47
|
+
expect(Array.isArray(x.artifacts) && x.artifacts.length > 0).toBe(true);
|
|
48
|
+
for (const p of x.artifacts)
|
|
49
|
+
expect(fs_1.default.existsSync(path_1.default.resolve(RESEARCH, p))).toBe(true);
|
|
50
|
+
// Ni secretos ni rutas de home reales viajan al repo. Se busca la ASIGNACIÓN
|
|
51
|
+
// (`token=…`, `"secret": …`), no la palabra suelta: la evidencia describe en prosa
|
|
52
|
+
// el fencing por token de generación, y un `/token/i` a secas convertía esa
|
|
53
|
+
// descripción legítima en un rojo — la clase de check que termina relajado a nada.
|
|
54
|
+
const serialized = JSON.stringify(x);
|
|
55
|
+
expect(serialized).not.toMatch(/\b(?:token|secret|password|api[-_]?key)\b"?\s*[:=]\s*"?(?!<REDACTED>)[\w-]{6,}/i);
|
|
56
|
+
expect(serialized).not.toMatch(/\/Users\/[^/"\s]+|\/home\/[^/"\s]+/);
|
|
57
|
+
});
|
|
58
|
+
it('el README declara explícitamente qué NO está certificado', () => {
|
|
59
|
+
// El día que `join` se certifique, esta aserción falla y obliga a actualizar el
|
|
60
|
+
// README — que es exactamente cuándo hay que actualizarlo.
|
|
61
|
+
const readme = fs_1.default.readFileSync(path_1.default.join(RESEARCH, 'README.md'), 'utf8');
|
|
62
|
+
const matrix = fs_1.default.readFileSync(MATRIX, 'utf8');
|
|
63
|
+
const joinCertified = /\| worktree join \|[^|]*supported/.test(matrix);
|
|
64
|
+
expect(joinCertified ? 'certificado' : readme.includes('No certificado por esta vía'))
|
|
65
|
+
.toBe(joinCertified ? 'certificado' : true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
// Guard estructural — la lección más cara de esta rama, convertida en regla.
|
|
7
|
+
//
|
|
8
|
+
// Tres sitios distintos comparaban rutas como STRINGS (`realpathSync(a) !== realpathSync(b)`).
|
|
9
|
+
// En Linux y macOS los tres pasaban; en Windows los tres mentían, porque el mismo directorio
|
|
10
|
+
// tiene más de una grafía. Arreglar uno solo habría dejado el bug vivo en los otros dos —
|
|
11
|
+
// exactamente el patrón "N hermanos, uno nunca recibió el tratamiento" que se repitió toda
|
|
12
|
+
// la iteración.
|
|
13
|
+
//
|
|
14
|
+
// La regla no enumera los tres sitios: prohíbe la FORMA. Un cuarto sitio escrito el mes que
|
|
15
|
+
// viene la hereda sin que nadie se acuerde de este incidente.
|
|
16
|
+
const fs_1 = __importDefault(require("fs"));
|
|
17
|
+
const path_1 = __importDefault(require("path"));
|
|
18
|
+
const SRC = path_1.default.resolve(__dirname, '..', '..', 'src');
|
|
19
|
+
function tsFiles(dir) {
|
|
20
|
+
return fs_1.default.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
|
|
21
|
+
const full = path_1.default.join(dir, e.name);
|
|
22
|
+
if (e.isDirectory())
|
|
23
|
+
return tsFiles(full);
|
|
24
|
+
return e.isFile() && e.name.endsWith('.ts') ? [full] : [];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** `realpathSync(...)` a cada lado de una comparación — la forma exacta del bug. Se ignoran
|
|
28
|
+
* los comentarios: este archivo y los tres arreglados EXPLICAN el patrón en prosa, y un
|
|
29
|
+
* guard que se dispara con su propia documentación termina desactivado. */
|
|
30
|
+
const STRING_COMPARE = /realpath[^\n]*\)\s*(?:===|!==)|(?:===|!==)\s*fs\.realpath/;
|
|
31
|
+
describe('identidad de rutas: filesystem, no strings', () => {
|
|
32
|
+
it('ningún módulo compara dos realpath con === / !==', () => {
|
|
33
|
+
const offenders = [];
|
|
34
|
+
for (const file of tsFiles(SRC)) {
|
|
35
|
+
// `\r\n` normalizado ANTES de partir: en JS `\r` es un terminador de línea, así
|
|
36
|
+
// que `.` NO lo matchea y `.*$` se corta antes del `\r` — el strip de comentarios
|
|
37
|
+
// de abajo no limpiaba nada en un checkout Windows y el guard se denunciaba a sí
|
|
38
|
+
// mismo por su propia documentación. Un guard contra un bug de plataforma roto
|
|
39
|
+
// por una suposición de plataforma: la misma clase que existe para atrapar.
|
|
40
|
+
const lines = fs_1.default.readFileSync(file, 'utf8').replace(/\r\n/g, '\n').split('\n');
|
|
41
|
+
lines.forEach((line, i) => {
|
|
42
|
+
const code = line.replace(/\/\/.*$/, '').replace(/^\s*\*.*$/, '');
|
|
43
|
+
if (STRING_COMPARE.test(code))
|
|
44
|
+
offenders.push(`${path_1.default.relative(SRC, file)}:${i + 1}: ${line.trim()}`);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
// El mensaje dice qué usar, no solo qué está mal: un guard que solo prohíbe se
|
|
48
|
+
// sortea con un `// eslint-disable` mental.
|
|
49
|
+
expect(offenders.join('\n') || 'sin ofensores').toBe('sin ofensores');
|
|
50
|
+
});
|
|
51
|
+
});
|