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,132 @@
|
|
|
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 os_1 = __importDefault(require("os"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const child_process_1 = require("child_process");
|
|
10
|
+
const git_fixture_1 = require("../../helpers/git-fixture");
|
|
11
|
+
const git_1 = require("../../../src/core/tracks/git");
|
|
12
|
+
function trackRef(worktreePath, branch) {
|
|
13
|
+
return {
|
|
14
|
+
trackId: 'x', worktreePath, branch,
|
|
15
|
+
ownership: [], sharedResources: [], dependsOn: [],
|
|
16
|
+
fencingToken: 'f'.repeat(32), phase: 'PREPARE_INTENT', readinessNonce: 'r'.repeat(32),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
describe('git adapter', () => {
|
|
20
|
+
let repo;
|
|
21
|
+
afterEach(() => { if (repo)
|
|
22
|
+
fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
23
|
+
test('changedPaths usa commits y conserva ambos lados de rename (R5.2, R5.4)', () => {
|
|
24
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
25
|
+
const base = (0, git_fixture_1.commitFile)(repo, 'old.ts', 'one');
|
|
26
|
+
fs_1.default.renameSync(path_1.default.join(repo, 'old.ts'), path_1.default.join(repo, 'new.ts'));
|
|
27
|
+
(0, child_process_1.execFileSync)('git', ['add', '-A'], { cwd: repo });
|
|
28
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', 'commit', '-m', 'rename'], { cwd: repo });
|
|
29
|
+
expect((0, git_1.changedPaths)(repo, base, 'HEAD')).toEqual([
|
|
30
|
+
{ status: 'R100', oldPath: 'old.ts', path: 'new.ts' },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
test('changedPaths reporta modificaciones simples con status M', () => {
|
|
34
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
35
|
+
const base = (0, git_fixture_1.commitFile)(repo, 'a.ts', 'one');
|
|
36
|
+
(0, git_fixture_1.commitFile)(repo, 'a.ts', 'two');
|
|
37
|
+
expect((0, git_1.changedPaths)(repo, base, 'HEAD')).toEqual([{ status: 'M', path: 'a.ts' }]);
|
|
38
|
+
});
|
|
39
|
+
test('changedPaths ignora el worktree sucio: solo compara commits (R5.2)', () => {
|
|
40
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
41
|
+
const base = (0, git_fixture_1.commitFile)(repo, 'a.ts', 'one');
|
|
42
|
+
(0, git_fixture_1.commitFile)(repo, 'a.ts', 'two');
|
|
43
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'a.ts'), 'dirty, uncommitted');
|
|
44
|
+
expect((0, git_1.changedPaths)(repo, base, 'HEAD')).toEqual([{ status: 'M', path: 'a.ts' }]);
|
|
45
|
+
});
|
|
46
|
+
test('mergeBase encuentra el ancestro común de dos ramas', () => {
|
|
47
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
48
|
+
const base = (0, git_fixture_1.commitFile)(repo, 'a.ts', 'one');
|
|
49
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', 'branch', 'side'], { cwd: repo });
|
|
50
|
+
(0, git_fixture_1.commitFile)(repo, 'b.ts', 'two');
|
|
51
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', 'checkout', '-q', 'side'], { cwd: repo });
|
|
52
|
+
(0, git_fixture_1.commitFile)(repo, 'c.ts', 'three');
|
|
53
|
+
expect((0, git_1.mergeBase)(repo, 'main', 'side')).toBe(base);
|
|
54
|
+
});
|
|
55
|
+
test.each(['valid-track', '..', '-x', 'a/b'])('git check-ref-format participa para %p (R1.3)', (id) => {
|
|
56
|
+
expect((0, git_1.gitCheckTrackId)(id)).toBe(id === 'valid-track');
|
|
57
|
+
});
|
|
58
|
+
describe('ownedWorktreeExists / removeOwnedBranch (Task 9, R4.2/R4.6/C11)', () => {
|
|
59
|
+
let worktreePath;
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
worktreePath = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-git-owned-wt-'));
|
|
62
|
+
fs_1.default.rmdirSync(worktreePath);
|
|
63
|
+
});
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
fs_1.default.rmSync(worktreePath, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
test('false cuando el destino no existe (nada que reconocer como propio todavía)', () => {
|
|
68
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
69
|
+
(0, git_fixture_1.commitFile)(repo, 'a.txt', 'x');
|
|
70
|
+
expect((0, git_1.ownedWorktreeExists)(repo, worktreePath, 'awm-track/x')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
test('false cuando el destino existe pero es genuinamente ajeno (R4.6 fail-closed)', () => {
|
|
73
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
74
|
+
(0, git_fixture_1.commitFile)(repo, 'a.txt', 'x');
|
|
75
|
+
fs_1.default.mkdirSync(worktreePath, { recursive: true });
|
|
76
|
+
fs_1.default.writeFileSync(path_1.default.join(worktreePath, 'foreign.txt'), 'ajeno');
|
|
77
|
+
expect((0, git_1.ownedWorktreeExists)(repo, worktreePath, 'awm-track/x')).toBe(false);
|
|
78
|
+
expect((0, git_1.foreignPathExists)(worktreePath)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
test('true cuando el destino YA es un worktree real registrado en la branch exacta (crash tras un `addWorktree` que sí corrió)', () => {
|
|
81
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
82
|
+
const baseSha = (0, git_fixture_1.commitFile)(repo, 'a.txt', 'x');
|
|
83
|
+
const ref = trackRef(worktreePath, 'awm-track/x');
|
|
84
|
+
(0, git_1.addOwnedWorktree)(repo, ref, baseSha);
|
|
85
|
+
expect((0, git_1.ownedWorktreeExists)(repo, worktreePath, 'awm-track/x')).toBe(true);
|
|
86
|
+
// Una branch distinta al mismo path, o el mismo path con otra
|
|
87
|
+
// branch, nunca cuentan como "nuestro" (identidad completa: path
|
|
88
|
+
// Y branch deterministas, no solo uno de los dos).
|
|
89
|
+
expect((0, git_1.ownedWorktreeExists)(repo, worktreePath, 'awm-track/other')).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
test('removeOwnedBranch borra la branch tras remover su worktree, y es idempotente si ya no existe', () => {
|
|
92
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
93
|
+
const baseSha = (0, git_fixture_1.commitFile)(repo, 'a.txt', 'x');
|
|
94
|
+
const ref = trackRef(worktreePath, 'awm-track/x');
|
|
95
|
+
(0, git_1.addOwnedWorktree)(repo, ref, baseSha);
|
|
96
|
+
(0, git_1.removeOwnedWorktree)(repo, worktreePath);
|
|
97
|
+
expect(() => (0, git_1.removeOwnedBranch)(repo, 'awm-track/x')).not.toThrow();
|
|
98
|
+
const branches = (0, child_process_1.execFileSync)('git', ['branch', '--list', 'awm-track/x'], { cwd: repo, encoding: 'utf8' });
|
|
99
|
+
expect(branches.trim()).toBe('');
|
|
100
|
+
// Idempotente: ya no existe, un segundo llamado (retry tras crash) no lanza.
|
|
101
|
+
expect(() => (0, git_1.removeOwnedBranch)(repo, 'awm-track/x')).not.toThrow();
|
|
102
|
+
});
|
|
103
|
+
// Task 13 (R4.10/C9): endurece el guard — nunca `--force`, nunca `-D`.
|
|
104
|
+
test('removeOwnedWorktree bloquea (nombrando paths) en vez de forzar si el worktree está sucio', () => {
|
|
105
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
106
|
+
const baseSha = (0, git_fixture_1.commitFile)(repo, 'a.txt', 'x');
|
|
107
|
+
const ref = trackRef(worktreePath, 'awm-track/x');
|
|
108
|
+
(0, git_1.addOwnedWorktree)(repo, ref, baseSha);
|
|
109
|
+
fs_1.default.writeFileSync(path_1.default.join(worktreePath, 'dirty.txt'), 'sin commitear');
|
|
110
|
+
expect(() => (0, git_1.removeOwnedWorktree)(repo, worktreePath)).toThrow(/sucio/);
|
|
111
|
+
expect(() => (0, git_1.removeOwnedWorktree)(repo, worktreePath)).toThrow(/dirty\.txt/);
|
|
112
|
+
// El worktree sigue vivo: nada se descartó a la fuerza.
|
|
113
|
+
expect(fs_1.default.existsSync(worktreePath)).toBe(true);
|
|
114
|
+
expect((0, git_1.ownedWorktreeExists)(repo, worktreePath, 'awm-track/x')).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
test('removeOwnedBranch rehúsa borrar una branch que sigue checked out en un worktree vivo (nunca -D)', () => {
|
|
117
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
118
|
+
const baseSha = (0, git_fixture_1.commitFile)(repo, 'a.txt', 'x');
|
|
119
|
+
const ref = trackRef(worktreePath, 'awm-track/x');
|
|
120
|
+
(0, git_1.addOwnedWorktree)(repo, ref, baseSha);
|
|
121
|
+
expect(() => (0, git_1.removeOwnedBranch)(repo, 'awm-track/x')).toThrow(/checked out/);
|
|
122
|
+
expect((0, git_1.branchExists)(repo, 'awm-track/x')).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
test('branchExists refleja existencia real de la branch determinista', () => {
|
|
125
|
+
repo = (0, git_fixture_1.initRepo)();
|
|
126
|
+
(0, git_fixture_1.commitFile)(repo, 'a.txt', 'x');
|
|
127
|
+
expect((0, git_1.branchExists)(repo, 'awm-track/nope')).toBe(false);
|
|
128
|
+
(0, child_process_1.execFileSync)('git', ['branch', 'awm-track/x'], { cwd: repo });
|
|
129
|
+
expect((0, git_1.branchExists)(repo, 'awm-track/x')).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// Task 11 (R6.2/R6.3/R6.6-R6.9/C7): matriz completa MERGE_HEAD × HEAD para
|
|
4
|
+
// `decideJoinReconciliation`. La función YA vive en `protocol.ts` desde
|
|
5
|
+
// Task 1 (`reconcileProtocol` la llama para su handler de `join-observation`)
|
|
6
|
+
// — por la regla de autoridad única, este archivo NO reimplementa nada; solo
|
|
7
|
+
// importa el re-export de `join.ts` (`export { decideJoinReconciliation }
|
|
8
|
+
// from './protocol'`, agregado por esta misma task) y prueba la matriz.
|
|
9
|
+
//
|
|
10
|
+
// Si esta matriz descubriera un caso que `decideJoinReconciliation` no
|
|
11
|
+
// cubre, el fix va EN `protocol.ts` y hay que volver a correr la exploración
|
|
12
|
+
// exhaustiva de Task 1 (`tests/core/tracks/protocol.test.ts`) en el mismo
|
|
13
|
+
// commit — un fix que solo viviera acá invalidaría la prueba del gate.
|
|
14
|
+
const join_1 = require("../../../src/core/tracks/join");
|
|
15
|
+
const types_1 = require("../../../src/core/tracks/types");
|
|
16
|
+
/** `expectedPlanHeadSha: 'plan'`, `expectedTrackHeadSha: 'track'` — mismos
|
|
17
|
+
* literales opacos que usa la matriz del plan (Task 11, Step 1). */
|
|
18
|
+
function intent() {
|
|
19
|
+
return { expectedPlanHeadSha: 'plan', expectedTrackHeadSha: 'track', strategy: types_1.JOIN_STRATEGY_NO_FF };
|
|
20
|
+
}
|
|
21
|
+
/** Helper local pedido por el plan: `obs(mergeHead, planHead, trackIsAncestor)`. */
|
|
22
|
+
function obs(mergeHead, planHead, trackIsAncestor) {
|
|
23
|
+
return { mergeHead, planHead, trackIsAncestor };
|
|
24
|
+
}
|
|
25
|
+
const cases = [
|
|
26
|
+
{ name: 'no empezó', observation: obs(null, 'plan', false), expected: { action: 'retry-merge' } },
|
|
27
|
+
{ name: 'conflicto propio', observation: obs('track', 'plan', false), expected: { action: 'abort-own-merge' } },
|
|
28
|
+
{ name: 'aplicado', observation: obs(null, 'merged', true), expected: { action: 'accept-merge', joinedCommitSha: 'merged' } },
|
|
29
|
+
{ name: 'MERGE_HEAD ajeno', observation: obs('other', 'plan', false), expected: { action: 'block', reason: 'MERGE_HEAD ajeno' } },
|
|
30
|
+
{ name: 'indemostrable', observation: obs(null, 'other', false), expected: { action: 'block', reason: 'estado de join indemostrable' } },
|
|
31
|
+
];
|
|
32
|
+
describe('decideJoinReconciliation — matriz MERGE_HEAD × HEAD (R6.8, R6.9)', () => {
|
|
33
|
+
test.each(cases)('$name (R6.8, R6.9)', ({ observation, expected }) => {
|
|
34
|
+
expect((0, join_1.decideJoinReconciliation)(intent(), observation)).toEqual(expected);
|
|
35
|
+
});
|
|
36
|
+
// Casos límite adicionales, fuera de la matriz literal del plan pero
|
|
37
|
+
// sobre el MISMO vocabulario — documentan por qué cada rama de
|
|
38
|
+
// `decideJoinReconciliation` existe (R6.6/R6.7: HEAD del plan movido).
|
|
39
|
+
test('HEAD del plan movió bajo nuestros pies sin merge en curso: indemostrable, nunca se asume (C7)', () => {
|
|
40
|
+
expect((0, join_1.decideJoinReconciliation)(intent(), obs(null, 'otro-plan-head', false)))
|
|
41
|
+
.toEqual({ action: 'block', reason: 'estado de join indemostrable' });
|
|
42
|
+
});
|
|
43
|
+
test('MERGE_HEAD propio pero el plan también se movió: NO se asume conflicto propio a ciegas (R6.6)', () => {
|
|
44
|
+
// `abort-own-merge` exige planHead === expectedPlanHeadSha; si además
|
|
45
|
+
// el HEAD del plan se movió, la ambigüedad (¿conflicto propio o plan
|
|
46
|
+
// movido?) es indemostrable con esta sola observación — bloquea, en
|
|
47
|
+
// vez de abortar un merge asumiendo un plan HEAD que ya no es el
|
|
48
|
+
// esperado. Documentado acá para que un futuro cambio en el orden de
|
|
49
|
+
// las ramas de `decideJoinReconciliation` no lo rompa en silencio.
|
|
50
|
+
expect((0, join_1.decideJoinReconciliation)(intent(), obs('track', 'otro-plan-head', false)))
|
|
51
|
+
.toEqual({ action: 'block', reason: 'estado de join indemostrable' });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,197 @@
|
|
|
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
|
+
// Task 10 (R5.2/R5.8/R5.9/R6.3/R6.4/R6.5/C7): precondiciones puras de join +
|
|
7
|
+
// lock de integración real. `validateJoinReadiness`/`planJoinOrder` son
|
|
8
|
+
// puras (sin I/O) — se prueban con fixtures en memoria. El lock y
|
|
9
|
+
// `assertPlanHead` (git.ts) sí tocan el mundo real (fs + git) y se prueban
|
|
10
|
+
// con un repo real, mismo patrón que `tests/commands/watch/lock.test.ts`.
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const os_1 = __importDefault(require("os"));
|
|
14
|
+
const child_process_1 = require("child_process");
|
|
15
|
+
const join_1 = require("../../../src/core/tracks/join");
|
|
16
|
+
const git_1 = require("../../../src/core/tracks/git");
|
|
17
|
+
const paths_1 = require("../../../src/core/journal/paths");
|
|
18
|
+
const store_1 = require("../../../src/core/journal/store");
|
|
19
|
+
function readyJoin(overrides = {}) {
|
|
20
|
+
return {
|
|
21
|
+
frozenHeadSha: 'track-head', actualHeadSha: 'track-head', dirtyPaths: [],
|
|
22
|
+
gatePass: true, liveJobs: 0, supervisorAlive: false, lockExists: false,
|
|
23
|
+
...overrides,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function git(cwd, ...args) {
|
|
27
|
+
(0, child_process_1.execFileSync)('git', ['-c', 'user.email=t@t.t', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', ...args], { cwd });
|
|
28
|
+
}
|
|
29
|
+
describe('validateJoinReadiness (R6.4/R6.5)', () => {
|
|
30
|
+
test('join exige freeze SHA, árbol limpio, gate recalculado, cero jobs y lock libre (R6.4, R6.5)', () => {
|
|
31
|
+
const base = readyJoin({
|
|
32
|
+
frozenHeadSha: 'track-head', actualHeadSha: 'track-head', dirtyPaths: [],
|
|
33
|
+
gatePass: true, liveJobs: 0, supervisorAlive: false, lockExists: false,
|
|
34
|
+
});
|
|
35
|
+
expect((0, join_1.validateJoinReadiness)(base)).toEqual({ ok: true });
|
|
36
|
+
for (const mutation of [
|
|
37
|
+
{ dirtyPaths: ['untracked.txt'] }, { gatePass: false }, { liveJobs: 1 },
|
|
38
|
+
{ supervisorAlive: true }, { lockExists: true }, { actualHeadSha: 'moved' },
|
|
39
|
+
]) {
|
|
40
|
+
expect((0, join_1.validateJoinReadiness)({ ...base, ...mutation })).toMatchObject({ ok: false });
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
test('dirty paths se nombran y nunca se descartan (R6.5)', () => {
|
|
44
|
+
expect((0, join_1.validateJoinReadiness)(readyJoin({ dirtyPaths: ['a.ts', 'new.txt'] })))
|
|
45
|
+
.toEqual({ ok: false, reasons: ['worktree sucio: a.ts, new.txt'] });
|
|
46
|
+
});
|
|
47
|
+
test('dirty paths se ordenan deterministicamente sin importar el orden de entrada (R6.5)', () => {
|
|
48
|
+
expect((0, join_1.validateJoinReadiness)(readyJoin({ dirtyPaths: ['z.ts', 'a.ts'] })))
|
|
49
|
+
.toEqual({ ok: false, reasons: ['worktree sucio: a.ts, z.ts'] });
|
|
50
|
+
});
|
|
51
|
+
test('múltiples hechos adversos se reportan TODOS, ninguno se pisa (R6.4)', () => {
|
|
52
|
+
const result = (0, join_1.validateJoinReadiness)(readyJoin({ gatePass: false, liveJobs: 2, lockExists: true }));
|
|
53
|
+
expect(result.ok).toBe(false);
|
|
54
|
+
if (!result.ok) {
|
|
55
|
+
expect(result.reasons).toEqual(expect.arrayContaining(['gate local rojo', '2 jobs vivos', 'lock de track retenido']));
|
|
56
|
+
expect(result.reasons).toHaveLength(3);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
describe('planJoinOrder (R5.7/R5.8/R5.9/C5)', () => {
|
|
61
|
+
test('ownership real fuera de scope serializa joins restantes, no ejecución (R5.8, R5.9)', () => {
|
|
62
|
+
const out = (0, join_1.planJoinOrder)(['a', 'b'], {
|
|
63
|
+
a: { outsideOwnership: ['outside.ts'], globalClasses: [] },
|
|
64
|
+
b: { outsideOwnership: [], globalClasses: [] },
|
|
65
|
+
});
|
|
66
|
+
expect(out).toEqual({
|
|
67
|
+
mode: 'serial-joins', order: ['a', 'b'],
|
|
68
|
+
violations: { a: ['outside.ts'] }, parallelInvalidatedBy: [],
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
test('una clase global tocada de verdad invalida el paralelismo de la cohorte (R5.7, C5)', () => {
|
|
72
|
+
const out = (0, join_1.planJoinOrder)(['a', 'b'], {
|
|
73
|
+
a: { outsideOwnership: ['package-lock.json'], globalClasses: ['lockfile:package-lock.json'] },
|
|
74
|
+
b: { outsideOwnership: [], globalClasses: [] },
|
|
75
|
+
});
|
|
76
|
+
expect(out.mode).toBe('serial-joins');
|
|
77
|
+
expect(out.parallelInvalidatedBy).toEqual(['a:lockfile:package-lock.json']);
|
|
78
|
+
});
|
|
79
|
+
test('ownership limpia en todos los tracks preserva paralelismo (caso feliz)', () => {
|
|
80
|
+
const out = (0, join_1.planJoinOrder)(['a', 'b'], {
|
|
81
|
+
a: { outsideOwnership: [], globalClasses: [] },
|
|
82
|
+
b: { outsideOwnership: [], globalClasses: [] },
|
|
83
|
+
});
|
|
84
|
+
expect(out).toEqual({ mode: 'parallel-joins', order: ['a', 'b'], violations: {}, parallelInvalidatedBy: [] });
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
describe('acquireIntegrationLock / releaseIntegrationLock (R5.8/R5.9/C7)', () => {
|
|
88
|
+
let repo;
|
|
89
|
+
beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-integration-lock-')); });
|
|
90
|
+
afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
91
|
+
test('adquisición exclusiva: una segunda adquisición falla mientras la primera vive', () => {
|
|
92
|
+
const l1 = (0, join_1.acquireIntegrationLock)(repo, { planJournalId: 'j-1', expectedPlanHeadSha: 'sha-1' });
|
|
93
|
+
expect(fs_1.default.existsSync((0, paths_1.integrationLockPath)(repo))).toBe(true);
|
|
94
|
+
expect(() => (0, join_1.acquireIntegrationLock)(repo, { planJournalId: 'j-1', expectedPlanHeadSha: 'sha-1' })).toThrow(/integración activa/);
|
|
95
|
+
(0, join_1.releaseIntegrationLock)(l1);
|
|
96
|
+
expect(fs_1.default.existsSync((0, paths_1.integrationLockPath)(repo))).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
test('el body persistido lleva planJournalId + expectedPlanHeadSha, no solo la identidad del proceso', () => {
|
|
99
|
+
const l1 = (0, join_1.acquireIntegrationLock)(repo, { planJournalId: 'j-42', expectedPlanHeadSha: 'sha-42' });
|
|
100
|
+
const onDisk = JSON.parse(fs_1.default.readFileSync((0, paths_1.integrationLockPath)(repo), 'utf8'));
|
|
101
|
+
expect(onDisk).toMatchObject({ planJournalId: 'j-42', expectedPlanHeadSha: 'sha-42', pid: process.pid });
|
|
102
|
+
(0, join_1.releaseIntegrationLock)(l1);
|
|
103
|
+
});
|
|
104
|
+
test('lock con identidad muerta PROBADA se reclama con reintento único', () => {
|
|
105
|
+
fs_1.default.mkdirSync(path_1.default.dirname((0, paths_1.integrationLockPath)(repo)), { recursive: true });
|
|
106
|
+
fs_1.default.writeFileSync((0, paths_1.integrationLockPath)(repo), JSON.stringify({
|
|
107
|
+
pid: 999999, startTime: 'gone', spawnNonce: 'x', argvDigest: 'y', processGroup: 999999, psArgsDigest: 'z',
|
|
108
|
+
planJournalId: 'j-old', expectedPlanHeadSha: 'sha-old',
|
|
109
|
+
}));
|
|
110
|
+
const l = (0, join_1.acquireIntegrationLock)(repo, { planJournalId: 'j-new', expectedPlanHeadSha: 'sha-new' });
|
|
111
|
+
expect(l.ref.pid).toBe(process.pid);
|
|
112
|
+
(0, join_1.releaseIntegrationLock)(l);
|
|
113
|
+
});
|
|
114
|
+
test('lock ilegible o con shape inválido => IntegrationLockBlockedError, JAMÁS se reclama', () => {
|
|
115
|
+
fs_1.default.mkdirSync(path_1.default.dirname((0, paths_1.integrationLockPath)(repo)), { recursive: true });
|
|
116
|
+
fs_1.default.writeFileSync((0, paths_1.integrationLockPath)(repo), '{roto');
|
|
117
|
+
expect(() => (0, join_1.acquireIntegrationLock)(repo, { planJournalId: 'j', expectedPlanHeadSha: 's' })).toThrow(join_1.IntegrationLockBlockedError);
|
|
118
|
+
fs_1.default.writeFileSync((0, paths_1.integrationLockPath)(repo), JSON.stringify({ pid: 1 })); // shape parcial: falta planJournalId/expectedPlanHeadSha
|
|
119
|
+
expect(() => (0, join_1.acquireIntegrationLock)(repo, { planJournalId: 'j', expectedPlanHeadSha: 's' })).toThrow(join_1.IntegrationLockBlockedError);
|
|
120
|
+
expect(fs_1.default.existsSync((0, paths_1.integrationLockPath)(repo))).toBe(true); // sigue ahí: nadie lo pisó
|
|
121
|
+
});
|
|
122
|
+
test('release solo borra el lock si sigue siendo el nuestro (spawnNonce coincide)', () => {
|
|
123
|
+
const l1 = (0, join_1.acquireIntegrationLock)(repo, { planJournalId: 'j-1', expectedPlanHeadSha: 'sha-1' });
|
|
124
|
+
// Simula que otro holder legítimo reclamó el lock después (ej. tras un
|
|
125
|
+
// release nuestro que ya ocurrió y una segunda adquisición ajena) —
|
|
126
|
+
// `releaseIntegrationLock` jamás borra un lock cuyo spawnNonce no es
|
|
127
|
+
// el propio.
|
|
128
|
+
fs_1.default.writeFileSync((0, paths_1.integrationLockPath)(repo), JSON.stringify({ ...l1.ref, spawnNonce: 'otro' }));
|
|
129
|
+
(0, join_1.releaseIntegrationLock)(l1);
|
|
130
|
+
expect(fs_1.default.existsSync((0, paths_1.integrationLockPath)(repo))).toBe(true);
|
|
131
|
+
fs_1.default.rmSync((0, paths_1.integrationLockPath)(repo));
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('assertPlanHead (R5.8/R5.9/C7): TOCTOU entre intent y cualquier operación git', () => {
|
|
135
|
+
let repo;
|
|
136
|
+
beforeEach(() => { repo = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-assert-head-')); git(repo, 'init', '-q', '-b', 'main'); });
|
|
137
|
+
afterEach(() => { fs_1.default.rmSync(repo, { recursive: true, force: true }); });
|
|
138
|
+
test('HEAD sin cambios: no lanza', () => {
|
|
139
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'a.txt'), 'x');
|
|
140
|
+
git(repo, 'add', '.');
|
|
141
|
+
git(repo, 'commit', '-qm', 'c1');
|
|
142
|
+
const sha = (0, git_1.headSha)(repo);
|
|
143
|
+
expect(() => (0, git_1.assertPlanHead)(repo, sha)).not.toThrow();
|
|
144
|
+
});
|
|
145
|
+
test('TOCTOU: adquirir el lock de integración con un expectedPlanHeadSha, mutar el HEAD del plan (commit humano), assertPlanHead BLOQUEA sin proceder', async () => {
|
|
146
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'a.txt'), 'x');
|
|
147
|
+
git(repo, 'add', '.');
|
|
148
|
+
git(repo, 'commit', '-qm', 'c1');
|
|
149
|
+
const expectedSha = (0, git_1.headSha)(repo);
|
|
150
|
+
// Intent: se adquiere el lock de integración con el SHA observado en
|
|
151
|
+
// este instante — modela el "expected SHA" que cualquier operación
|
|
152
|
+
// Git de join (Task 11) debe reverificar antes/después de mutar.
|
|
153
|
+
const lock = (0, join_1.acquireIntegrationLock)(repo, { planJournalId: 'j-1', expectedPlanHeadSha: expectedSha });
|
|
154
|
+
expect(() => (0, git_1.assertPlanHead)(repo, expectedSha)).not.toThrow();
|
|
155
|
+
// Un humano muta el repo del plan MIENTRAS el lock está retenido —
|
|
156
|
+
// "un humano puede mutar el repo, pero la mutación se detecta y
|
|
157
|
+
// bloquea" (Step 5 del plan): jamás se procede con un merge sobre un
|
|
158
|
+
// HEAD que ya no es el que se congeló al pedir el lock.
|
|
159
|
+
fs_1.default.writeFileSync(path_1.default.join(repo, 'b.txt'), 'y');
|
|
160
|
+
git(repo, 'add', '.');
|
|
161
|
+
git(repo, 'commit', '-qm', 'commit humano fuera de banda');
|
|
162
|
+
// La operación de merge simulada consulta `assertPlanHead` con el
|
|
163
|
+
// MISMO `expectedPlanHeadSha` que persistió el lock — BLOQUEA sin
|
|
164
|
+
// haber tocado el repo (sin merge, R6/C7's "BLOCKED sin merge").
|
|
165
|
+
expect(() => (0, git_1.assertPlanHead)(repo, expectedSha)).toThrow(/HEAD del plan cambió/);
|
|
166
|
+
// El SHA drifteado se ve en el mensaje — no es un bloqueo silencioso.
|
|
167
|
+
try {
|
|
168
|
+
(0, git_1.assertPlanHead)(repo, expectedSha);
|
|
169
|
+
throw new Error('no debería llegar acá');
|
|
170
|
+
}
|
|
171
|
+
catch (e) {
|
|
172
|
+
expect(e.message).toContain(expectedSha);
|
|
173
|
+
}
|
|
174
|
+
(0, join_1.releaseIntegrationLock)(lock);
|
|
175
|
+
});
|
|
176
|
+
test('fail-closed: repo ilegible (git falla) nunca se interpreta como "sin cambios"', () => {
|
|
177
|
+
const gone = path_1.default.join(repo, 'no-existe-mas');
|
|
178
|
+
expect(() => (0, git_1.assertPlanHead)(gone, 'cualquier-sha')).toThrow(/indemostrable/);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe('stopControllerGenerationConfirmed (R5.8/C7)', () => {
|
|
182
|
+
const BRANCH = 'main';
|
|
183
|
+
let planRoot;
|
|
184
|
+
beforeEach(() => { planRoot = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), 'awm-stop-gen-')); (0, store_1.initJournal)(planRoot, BRANCH); });
|
|
185
|
+
afterEach(() => { fs_1.default.rmSync(planRoot, { recursive: true, force: true }); });
|
|
186
|
+
test('sin generación activa: no-op, no lanza', async () => {
|
|
187
|
+
await expect((0, join_1.stopControllerGenerationConfirmed)(planRoot, BRANCH, { termGraceMs: 10, killGraceMs: 10 })).resolves.toBeUndefined();
|
|
188
|
+
});
|
|
189
|
+
test('generación activa sin processRef/wrapperRef vivos: se marca terminated sin intentar señales', async () => {
|
|
190
|
+
const s = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
191
|
+
s.generations.push({ n: 1, token: 'tok-1', state: 'active', launchedAt: new Date().toISOString() });
|
|
192
|
+
(0, store_1.writeJournal)(planRoot, BRANCH, s);
|
|
193
|
+
await (0, join_1.stopControllerGenerationConfirmed)(planRoot, BRANCH, { termGraceMs: 10, killGraceMs: 10 });
|
|
194
|
+
const after = (0, store_1.readJournal)(planRoot, BRANCH).state;
|
|
195
|
+
expect(after.generations[0].state).toBe('terminated');
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const ownership_1 = require("../../../src/core/tracks/ownership");
|
|
4
|
+
test('sopa de globs soportados: dir/, dir/*, dir/** cubren descendientes (R5.1, R5.4)', () => {
|
|
5
|
+
for (const owner of ['cli/src/', 'cli/src/*', 'cli/src/**']) {
|
|
6
|
+
expect((0, ownership_1.ownershipPrefix)(owner)).toBe('cli/src/');
|
|
7
|
+
}
|
|
8
|
+
expect((0, ownership_1.ownershipPrefix)('cli/src/a.ts')).toBe('cli/src/a.ts');
|
|
9
|
+
});
|
|
10
|
+
test('un glob intermedio NO habilita paralelismo: falla cerrado (R5.1, R5.3)', () => {
|
|
11
|
+
// Antes esto no colisionaba con nada y dejaba pasar la cohorte entera.
|
|
12
|
+
expect(() => (0, ownership_1.ownershipPrefix)('src/**/a.ts')).toThrow(/no soporta este glob/);
|
|
13
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
14
|
+
track('a', ['src/**/a.ts']), track('b', ['src/lib/a.ts']),
|
|
15
|
+
])).toMatchObject({ parallel: false });
|
|
16
|
+
expect((0, ownership_1.assessDeclaredIndependence)([track('a', ['src/**/a.ts']), track('b', ['docs/'])]).reasons)
|
|
17
|
+
.toContain('unsupported-glob:src/**/a.ts');
|
|
18
|
+
});
|
|
19
|
+
test('un glob inexpandible tampoco PRUEBA propiedad post-hoc (R5.8)', () => {
|
|
20
|
+
expect((0, ownership_1.assessActualOwnership)(track('a', ['src/**/a.ts']), [{ status: 'M', path: 'src/lib/a.ts' }]))
|
|
21
|
+
.toMatchObject({ outsideOwnership: ['src/lib/a.ts'] });
|
|
22
|
+
});
|
|
23
|
+
test('colisiona exacto, case-insensitive y por descendiente (R5.1, R5.3, R5.4)', () => {
|
|
24
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
25
|
+
track('a', ['src/api/']), track('b', ['SRC/API/user.ts']),
|
|
26
|
+
])).toMatchObject({ parallel: false, reasons: ['path:SRC/API/user.ts'] });
|
|
27
|
+
});
|
|
28
|
+
test('rename cuenta path viejo y nuevo (R5.4)', () => {
|
|
29
|
+
const out = (0, ownership_1.assessActualOwnership)(track('a', ['src/new.ts']), [
|
|
30
|
+
{ status: 'R100', oldPath: 'src/old.ts', path: 'src/new.ts' },
|
|
31
|
+
]);
|
|
32
|
+
expect(out.outsideOwnership).toEqual(['src/old.ts']);
|
|
33
|
+
});
|
|
34
|
+
test('un solo lockfile invalida toda la cohorte (R5.7, C5, CA-4.3)', () => {
|
|
35
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
36
|
+
track('a', ['src/a.ts']), track('b', ['package-lock.json']),
|
|
37
|
+
])).toMatchObject({ parallel: false, reasons: ['global:lockfile:package-lock.json'] });
|
|
38
|
+
});
|
|
39
|
+
test('recursos usan clase:valor canónico y colisionan por igualdad (R5.5, R5.6)', () => {
|
|
40
|
+
expect((0, ownership_1.canonicalResource)('port:5432')).toBe('port:5432');
|
|
41
|
+
expect(() => (0, ownership_1.canonicalResource)('5432')).toThrow(/<clase>:<valor>/);
|
|
42
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
43
|
+
track('a', ['a'], ['db:dev']), track('b', ['b'], ['db:dev']),
|
|
44
|
+
])).toMatchObject({ parallel: false, reasons: ['resource:db:dev'] });
|
|
45
|
+
});
|
|
46
|
+
test('canonicalResource case-folds el valor, no solo la clase (R5.5, R5.6)', () => {
|
|
47
|
+
expect((0, ownership_1.canonicalResource)('db:Dev')).toBe((0, ownership_1.canonicalResource)('db:dev'));
|
|
48
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
49
|
+
track('a', ['a'], ['db:Dev']), track('b', ['b'], ['db:dev']),
|
|
50
|
+
])).toMatchObject({ parallel: false, reasons: ['resource:db:dev'] });
|
|
51
|
+
});
|
|
52
|
+
test('no afirma observar recursos runtime no declarados (C10)', () => {
|
|
53
|
+
const out = (0, ownership_1.assessDeclaredIndependence)([track('a', ['a']), track('b', ['b'])]);
|
|
54
|
+
expect(out).toEqual({ parallel: true, reasons: [] });
|
|
55
|
+
// La garantía es sobre declaraciones + archivos; no existe probe de puertos/bases.
|
|
56
|
+
});
|
|
57
|
+
test('paths estilo Windows normalizan a posix antes de comparar (R5.3)', () => {
|
|
58
|
+
expect((0, ownership_1.ownershipPrefix)('cli\\src\\')).toBe('cli/src/');
|
|
59
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
60
|
+
track('a', ['cli\\src\\a.ts']), track('b', ['cli/src/a.ts']),
|
|
61
|
+
])).toMatchObject({ parallel: false });
|
|
62
|
+
expect((0, ownership_1.assessActualOwnership)(track('a', ['cli/src/']), [{ status: 'M', path: 'cli\\src\\deep\\a.ts' }]))
|
|
63
|
+
.toMatchObject({ outsideOwnership: [] });
|
|
64
|
+
});
|
|
65
|
+
test('manifest (package.json) es clase global aunque solo un track lo toque (C5)', () => {
|
|
66
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
67
|
+
track('a', ['src/a.ts']), track('b', ['package.json']),
|
|
68
|
+
])).toMatchObject({ parallel: false, reasons: ['global:manifest:package.json'] });
|
|
69
|
+
expect((0, ownership_1.assessActualOwnership)(track('a', ['src/a.ts']), [{ status: 'M', path: 'package.json' }]).globalClasses)
|
|
70
|
+
.toEqual(['manifest:package.json']);
|
|
71
|
+
});
|
|
72
|
+
test('migraciones y snapshots también cuentan como clase global (C5)', () => {
|
|
73
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
74
|
+
track('a', ['src/a.ts']), track('b', ['migrations/0001_init.sql']),
|
|
75
|
+
])).toMatchObject({ parallel: false, reasons: ['global:migration:migrations/0001_init.sql'] });
|
|
76
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
77
|
+
track('a', ['src/a.ts']), track('b', ['__snapshots__/a.snap']),
|
|
78
|
+
])).toMatchObject({ parallel: false, reasons: ['global:snapshot:__snapshots__/a.snap'] });
|
|
79
|
+
});
|
|
80
|
+
test('directorios generados (dist/generated/coverage) también cuentan como clase global (C5)', () => {
|
|
81
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
82
|
+
track('a', ['src/a.ts']), track('b', ['dist/bundle.js']),
|
|
83
|
+
])).toMatchObject({ parallel: false, reasons: ['global:generated:dist/bundle.js'] });
|
|
84
|
+
expect((0, ownership_1.assessActualOwnership)(track('a', ['src/a.ts']), [{ status: 'M', path: 'coverage/lcov.info' }]).globalClasses)
|
|
85
|
+
.toEqual(['generated:coverage/lcov.info']);
|
|
86
|
+
});
|
|
87
|
+
test('recursos declarados distintos no colisionan; ownership propio no reporta outsideOwnership', () => {
|
|
88
|
+
expect((0, ownership_1.assessDeclaredIndependence)([
|
|
89
|
+
track('a', ['a'], ['db:dev']), track('b', ['b'], ['db:staging']),
|
|
90
|
+
])).toEqual({ parallel: true, reasons: [] });
|
|
91
|
+
expect((0, ownership_1.assessActualOwnership)(track('a', ['cli/src/']), [{ status: 'M', path: 'cli/src/a.ts' }]))
|
|
92
|
+
.toEqual({ outsideOwnership: [], globalClasses: [] });
|
|
93
|
+
});
|
|
94
|
+
function track(trackId, ownership, sharedResources = []) {
|
|
95
|
+
return { trackId, taskIds: [trackId], ownership, sharedResources, dependsOn: [] };
|
|
96
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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 plan_parser_1 = require("../../../src/core/tracks/plan-parser");
|
|
9
|
+
const fixture = (name) => fs_1.default.readFileSync(path_1.default.join(__dirname, '../../fixtures/tracks', name), 'utf8');
|
|
10
|
+
/** Narrowing explícito: el retorno es una unión discriminada y `strict` no deja acceder sin estrecharla. */
|
|
11
|
+
const parallel = (source) => {
|
|
12
|
+
const parsed = (0, plan_parser_1.parseTrackPlan)(source, () => true);
|
|
13
|
+
if (parsed.mode !== 'parallel-candidate')
|
|
14
|
+
throw new Error(`esperaba parallel-candidate, obtuve ${parsed.mode}`);
|
|
15
|
+
return parsed;
|
|
16
|
+
};
|
|
17
|
+
describe('parseTrackPlan', () => {
|
|
18
|
+
test('parsea membresía, ownership y argv sin shell (R1.1, R1.4, C4)', () => {
|
|
19
|
+
const p = parallel(fixture('two-independent.md'));
|
|
20
|
+
expect(p.integration).toEqual({
|
|
21
|
+
argv: ['npm', 'test', '--', '--runInBand'],
|
|
22
|
+
paths: ['cli/src/**', 'cli/tests/**'],
|
|
23
|
+
});
|
|
24
|
+
expect(p.tracks.cli.taskIds).toEqual(['1']);
|
|
25
|
+
expect(p.tracks.cli.ownership).toEqual(['cli/src/a.ts']);
|
|
26
|
+
});
|
|
27
|
+
test('ausencia completa conserva serial legacy (R1.2)', () => {
|
|
28
|
+
expect((0, plan_parser_1.parseTrackPlan)(fixture('legacy-serial.md'), () => true)).toEqual({ mode: 'serial', reason: 'no-tracks' });
|
|
29
|
+
});
|
|
30
|
+
test.each(['', '.', '..', '-x', 'a/b', 'a\\b'])('rechaza id peligroso %p aunque git lo aceptara parcialmente (R1.3)', (id) => {
|
|
31
|
+
const src = fixture('two-independent.md').replaceAll('cli', id);
|
|
32
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/track id inválido/);
|
|
33
|
+
});
|
|
34
|
+
test('rechaza membresía sin fila y fila sin membresía (R1.6)', () => {
|
|
35
|
+
const src = fixture('two-independent.md').replace('| docs | none | [] |', '| extra | none | [] |');
|
|
36
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/coincidencia exacta/);
|
|
37
|
+
});
|
|
38
|
+
test('degrada a serial si shared resources falta o Depends on no es none (R1.7, R1.8)', () => {
|
|
39
|
+
expect((0, plan_parser_1.parseTrackPlan)(fixture('two-independent.md').replace('[] |', ' |'), () => true)).toMatchObject({ mode: 'serial' });
|
|
40
|
+
expect((0, plan_parser_1.parseTrackPlan)(fixture('two-independent.md').replace('| docs | none |', '| docs | cli |'), () => true)).toMatchObject({ mode: 'serial' });
|
|
41
|
+
});
|
|
42
|
+
test('integration argv debe ser JSON string[] no vacío (C4)', () => {
|
|
43
|
+
const src = fixture('two-independent.md').replace('["npm","test","--","--runInBand"]', 'npm test');
|
|
44
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/Integration argv/);
|
|
45
|
+
});
|
|
46
|
+
test('la ÚLTIMA task con Files y sin Track también se rechaza (R1.6)', () => {
|
|
47
|
+
// El chequeo del loop solo dispara al ver el siguiente `### Task`; sin este caso
|
|
48
|
+
// la última task del documento entra a paralelo con sus archivos sin dueño.
|
|
49
|
+
const src = fixture('two-independent.md').replace('**Track:** docs\n', '');
|
|
50
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/tiene Files pero no Track/);
|
|
51
|
+
});
|
|
52
|
+
// --- Step 5: casos adicionales bajo-especificados por el plan ---
|
|
53
|
+
test('rechaza track id duplicado en la tabla ## Tracks', () => {
|
|
54
|
+
const src = fixture('two-independent.md').replace('| docs | none | [] |', '| cli | none | [] |');
|
|
55
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/track duplicado/);
|
|
56
|
+
});
|
|
57
|
+
test('rechaza una segunda línea **Track:** para la misma task', () => {
|
|
58
|
+
const src = fixture('two-independent.md').replace('**Track:** cli\n', '**Track:** cli\n**Track:** cli\n');
|
|
59
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/exactamente un Track/);
|
|
60
|
+
});
|
|
61
|
+
test('rechaza path absoluto en - Modify: (fuera del repo)', () => {
|
|
62
|
+
const src = fixture('two-independent.md').replace('`cli/src/a.ts`', '`/etc/passwd`');
|
|
63
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/Files path fuera del repo/);
|
|
64
|
+
});
|
|
65
|
+
test('rechaza escape de repo vía ../ en - Modify:', () => {
|
|
66
|
+
const src = fixture('two-independent.md').replace('`cli/src/a.ts`', '`../../etc/passwd`');
|
|
67
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/Files path fuera del repo/);
|
|
68
|
+
});
|
|
69
|
+
test('Integration argv/paths con elemento no-string (número) es rechazado (C4)', () => {
|
|
70
|
+
const src = fixture('two-independent.md').replace('["npm","test","--","--runInBand"]', '["npm",1]');
|
|
71
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/Integration argv/);
|
|
72
|
+
});
|
|
73
|
+
test('Integration paths con elemento no-string (número) es rechazado (C4)', () => {
|
|
74
|
+
const src = fixture('two-independent.md').replace('["cli/src/**","cli/tests/**"]', '["cli/src/**",2]');
|
|
75
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/Integration paths/);
|
|
76
|
+
});
|
|
77
|
+
test('rechaza track declarado en la tabla que ninguna task reclama vía **Track:** (R1.6)', () => {
|
|
78
|
+
// Guarda específicamente la rama `members.size !== declared.size`: una fila de
|
|
79
|
+
// ## Tracks ("extra") sin ninguna task que la reclame debe fallar por
|
|
80
|
+
// coincidencia exacta, distinto del caso ya cubierto de membresía sin fila.
|
|
81
|
+
const src = fixture('two-independent.md').replace('| docs | none | [] |', '| docs | none | [] |\n| extra | none | [] |');
|
|
82
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/coincidencia exacta/);
|
|
83
|
+
});
|
|
84
|
+
test('un segundo encabezado ## Tracks hace que sus filas se ignoren silenciosamente; ' +
|
|
85
|
+
'la membresía a la fila "perdida" sigue fallando por coincidencia exacta (documenta comportamiento real, no deseado idealmente)', () => {
|
|
86
|
+
// El parser de tabla solo usa el PRIMER `## Tracks` (findIndex) y corta en la
|
|
87
|
+
// primera línea que empieza con `## ` tras ese punto — incluida una segunda
|
|
88
|
+
// `## Tracks`. Por eso la fila de `docs` colocada después del segundo encabezado
|
|
89
|
+
// nunca entra a `declared`, y la membresía posterior de Task 2 a `docs` revienta
|
|
90
|
+
// por "coincidencia exacta" en vez de por un error específico de tabla duplicada.
|
|
91
|
+
const src = fixture('two-independent.md').replace('| docs | none | [] |', '\n## Tracks\n\n| Track | Depends on | Shared resources |\n|---|---|---|\n| docs | none | [] |');
|
|
92
|
+
expect(() => (0, plan_parser_1.parseTrackPlan)(src, () => true)).toThrow(/coincidencia exacta/);
|
|
93
|
+
});
|
|
94
|
+
});
|