agentic-workflow-manager 9.20.0 → 9.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/src/commands/change/index.js +2 -2
  2. package/dist/src/commands/changes/index.js +2 -1
  3. package/dist/src/core/changes/derive.js +21 -8
  4. package/dist/src/core/changes/git.js +37 -6
  5. package/dist/src/core/changes/range.js +28 -13
  6. package/dist/src/core/process/model.js +153 -10
  7. package/dist/tests/commands/change/new.test.js +28 -1
  8. package/dist/tests/commands/change/validate.test.js +3 -3
  9. package/dist/tests/commands/changes/list.test.js +1 -0
  10. package/dist/tests/commands/process.test.js +4 -0
  11. package/dist/tests/core/changes/derive.test.js +75 -2
  12. package/dist/tests/core/changes/git.test.js +83 -1
  13. package/dist/tests/core/changes/helpers.js +2 -2
  14. package/dist/tests/core/changes/list.test.js +6 -5
  15. package/dist/tests/core/changes/range.test.js +89 -0
  16. package/dist/tests/core/dashboard/write-html.test.js +2 -3
  17. package/dist/tests/core/evidence/store.test.js +2 -3
  18. package/dist/tests/core/ledger/store.test.js +3 -9
  19. package/dist/tests/core/process/discover.test.js +21 -0
  20. package/dist/tests/core/process/model.test.js +338 -0
  21. package/dist/tests/helpers/platform.js +27 -0
  22. package/dist/tests/structural/change-record-git-and-platform-conventions.test.js +6 -5
  23. package/dist/tests/structural/native-release-artifacts.test.js +295 -46
  24. package/dist/tests/structural/process-model-description-contract.test.js +34 -0
  25. package/package.json +1 -1
  26. package/prebuilds/darwin-arm64/secure_fs.node +0 -0
  27. package/prebuilds/darwin-x64/secure_fs.node +0 -0
  28. package/prebuilds/win32-arm64/secure_fs.node +0 -0
  29. package/prebuilds/win32-x64/secure_fs.node +0 -0
@@ -129,6 +129,18 @@ describe('deriveFromCommits and deriveModules (no repository)', () => {
129
129
  expect(result.diagnostic.message).toContain('abc\\ndef');
130
130
  expect(result.diagnostic.message.length).toBeLessThanOrEqual(300);
131
131
  });
132
+ test('R3.3 (amended) type and scope come from the focus commits; semver and breaking from all', () => {
133
+ const all = [c('docs', 'plans'), c('ci', 'release'), c('docs', 'plans'), c('feat', 'plans', true)];
134
+ const focus = [c('ci', 'release')];
135
+ expect((0, derive_1.deriveFromCommits)(all, focus)).toEqual({ type: 'ci', scope: 'release', semver: 'major', breaking: true });
136
+ });
137
+ test('R3.3 (amended) an empty, unusable or non-array focus falls back to all commits', () => {
138
+ const all = [c('docs', 'plans'), c('ci', 'release')];
139
+ const expected = (0, derive_1.deriveFromCommits)(all);
140
+ for (const focus of [[], [{ type: 'x y', scope: null, breaking: false, subject: '' }], undefined, null, 'x']) {
141
+ expect((0, derive_1.deriveFromCommits)(all, focus)).toEqual(expected);
142
+ }
143
+ });
132
144
  });
133
145
  describe('deriveFields in a repository', () => {
134
146
  let repo;
@@ -293,11 +305,29 @@ describe('deriveFields in a repository', () => {
293
305
  const result = (0, derive_1.deriveFields)(nonRepoDir(), 'a'.repeat(40));
294
306
  expect(result).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'git-failed' }) });
295
307
  });
296
- test('deriveFields works from a subdirectory of the repository', () => {
308
+ test('deriveFields reports git-failed when only the focus log (the one that drops docs/plans) fails', () => {
297
309
  const base = branch();
310
+ repo.commit({ 'cli/src/a.ts': '1' }, 'feat(c): a');
311
+ const mb = (0, git_1.mergeBase)(repo.root, base);
312
+ // Only the second `git log` carries the docs/plans exclusion: the full log, HEAD and the diff all succeed.
313
+ const git = (0, helpers_1.spyOnGit)((args) => { if (args.includes(':(top,exclude,literal)docs/plans'))
314
+ throw new Error('focus log failed'); });
315
+ try {
316
+ expect((0, derive_1.deriveFields)(repo.root, mb)).toEqual({ ok: false, diagnostic: expect.objectContaining({ code: 'git-failed' }) });
317
+ }
318
+ finally {
319
+ git.restore();
320
+ }
321
+ expect(git.calls.filter((a) => a[0] === 'log')).toHaveLength(2);
322
+ });
323
+ test('deriveFields works from a subdirectory of the repository, and still drops the top-level docs/plans there', () => {
324
+ const base = branch();
325
+ // Two plan-only commits: were docs/plans read relative to cli/ (no `top`), they would choose the scope.
326
+ repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan it');
327
+ repo.commit({ 'docs/plans/p.md': '2' }, 'docs(plans): amend it');
298
328
  repo.commit({ 'cli/src/a.ts': '1', 'top.ts': '1' }, 'feat(c): top and sub');
299
329
  const sub = path_1.default.join(repo.root, 'cli');
300
- expect((0, derive_1.deriveFields)(sub, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'c', semver: 'minor', breaking: false, modules: ['.', 'cli/src'] } });
330
+ expect((0, derive_1.deriveFields)(sub, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'feat', scope: 'c', semver: 'minor', breaking: false, modules: ['.', 'cli/src', 'docs/plans'] } });
301
331
  });
302
332
  test('R3.3 a signed breaking commit is derived even with log.showSignature on', () => {
303
333
  const base = branch();
@@ -432,6 +462,49 @@ describe('deriveFields in a repository', () => {
432
462
  }
433
463
  expect(moved).toBe(true);
434
464
  });
465
+ test('R3.3 (amended) plan-only commits do not choose type or scope (the #228 shape)', () => {
466
+ const base = branch();
467
+ repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan it');
468
+ repo.commit({ '.github/workflows/ci.yml': '1' }, 'ci(release): shard');
469
+ repo.commit({ 'docs/plans/p.md': '2' }, 'docs(plans): amend A1');
470
+ repo.commit({ 'docs/plans/p.md': '3' }, 'docs(plans): amend A2');
471
+ repo.commit({ '.github/workflows/release.yml': '1' }, 'ci(release): gate it');
472
+ repo.commit({ 'docs/decisions.md': '1' }, 'docs(decisions): record it');
473
+ expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'ci', scope: 'release', semver: 'none', breaking: false, modules: ['.github/workflows', 'docs', 'docs/plans'] } });
474
+ });
475
+ test('R3.3 (amended) a plan-only commit still counts for semver and breaking', () => {
476
+ const base = branch();
477
+ repo.commit({ 'docs/plans/p.md': '1' }, 'feat(plans)!: plan a breaking change');
478
+ repo.commit({ 'cli/src/a.ts': '1' }, 'fix(process): code');
479
+ expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'fix', scope: 'process', semver: 'major', breaking: true, modules: ['cli/src', 'docs/plans'] } });
480
+ });
481
+ test('R3.3 (amended) a commit touching docs/plans and code counts for type and scope', () => {
482
+ const base = branch();
483
+ repo.commit({ 'cli/src/a.ts': '1' }, 'chore(x): first');
484
+ repo.commit({ 'docs/plans/p.md': '1', 'cli/src/b.ts': '1' }, 'docs(plans): mixed');
485
+ repo.commit({ 'docs/plans/p.md': '2', 'cli/src/c.ts': '1' }, 'docs(plans): mixed again');
486
+ expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toMatchObject({ ok: true, fields: { type: 'chore', scope: 'plans' } });
487
+ });
488
+ test('R3.3 (amended) a pure planning branch keeps type and scope from its plan commits', () => {
489
+ const base = branch();
490
+ repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan only');
491
+ expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'docs', scope: 'plans', semver: 'none', breaking: false, modules: ['docs/plans'] } });
492
+ });
493
+ test('R3.3 (amended) only the top-level docs/plans is excluded, not similarly named directories', () => {
494
+ const base = branch();
495
+ // docs/plans-old decides the type (the only feat) and cli/docs/plans the scope (nested 2 against archive 1): excluding
496
+ // either directory as well would change the result (chore, or the archive/nested tie broken to archive).
497
+ repo.commit({ 'docs/plans-old/p.md': '1' }, 'feat(archive): old plans');
498
+ repo.commit({ 'cli/docs/plans/p.md': '1' }, 'chore(nested): nested plans');
499
+ repo.commit({ 'cli/src/a.ts': '1' }, 'chore(nested): code');
500
+ expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toMatchObject({ ok: true, fields: { type: 'feat', scope: 'nested' } });
501
+ });
502
+ test('R3.3 (amended) non-conventional code commits leave type and scope to every conventional commit, here the plan', () => {
503
+ const base = branch();
504
+ repo.commit({ 'cli/x.ts': '1' }, 'Update the code');
505
+ repo.commit({ 'docs/plans/p.md': '1' }, 'docs(plans): plan it');
506
+ expect((0, derive_1.deriveFields)(repo.root, (0, git_1.mergeBase)(repo.root, base))).toEqual({ ok: true, fields: { type: 'docs', scope: 'plans', semver: 'none', breaking: false, modules: ['cli', 'docs/plans'] } });
507
+ });
435
508
  });
436
509
  describe('commitsFromRecords: hostile subjects are never handed to the release parser (R3.3), in linear time', () => {
437
510
  // The release parser's header regex backtracks quadratically on blanks followed by a line terminator its `.` cannot cross, so a
@@ -68,7 +68,7 @@ describe('git functions in a repository', () => {
68
68
  test('returns null for a directory that does not exist', () => {
69
69
  expect((0, git_1.runGit)(path_1.default.join(repo.home, 'missing'), ['status'])).toBeNull();
70
70
  });
71
- test('R3.1 resolves base: explicit, origin/HEAD, main, master', () => {
71
+ test('R3.1 resolves base: explicit, origin/HEAD, origin/main, origin/master, main, master', () => {
72
72
  branch();
73
73
  expect(baseOf(repo.root)).toBe('main');
74
74
  expect(baseOf(repo.root, 'main')).toBe('main');
@@ -76,6 +76,39 @@ describe('git functions in a repository', () => {
76
76
  repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
77
77
  expect(baseOf(repo.root)).toBe('refs/remotes/origin/HEAD');
78
78
  });
79
+ test('R3.1 without origin/HEAD, origin/main wins over a local main', () => {
80
+ branch();
81
+ repo.git('update-ref', 'refs/remotes/origin/main', 'main');
82
+ expect(baseOf(repo.root)).toBe('refs/remotes/origin/main');
83
+ });
84
+ test('R3.1 origin/master is used when there is no origin/HEAD nor origin/main, before a local main', () => {
85
+ branch();
86
+ repo.git('update-ref', 'refs/remotes/origin/master', 'main');
87
+ expect(baseOf(repo.root)).toBe('refs/remotes/origin/master');
88
+ });
89
+ test('R3.1 with origin/main and origin/master on different commits and no origin/HEAD, origin/main is the base', () => {
90
+ branch();
91
+ repo.commit({ 'a.ts': '1' }, 'feat: a');
92
+ repo.git('update-ref', 'refs/remotes/origin/master', 'main');
93
+ repo.git('update-ref', 'refs/remotes/origin/main', 'HEAD');
94
+ expect(repo.git('rev-parse', 'refs/remotes/origin/main')).not.toBe(repo.git('rev-parse', 'refs/remotes/origin/master'));
95
+ expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: 'refs/remotes/origin/main', sha: repo.git('rev-parse', 'refs/remotes/origin/main') });
96
+ });
97
+ test('R3.1 origin/HEAD still wins over origin/main', () => {
98
+ branch();
99
+ repo.git('update-ref', 'refs/remotes/origin/main', 'main');
100
+ repo.git('update-ref', 'refs/remotes/origin/trunk', 'main');
101
+ repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk');
102
+ expect(baseOf(repo.root)).toBe('refs/remotes/origin/HEAD');
103
+ });
104
+ test('R3.1 a local branch named origin/main is not taken for the remote candidate', () => {
105
+ repo.git('branch', 'origin/main');
106
+ expect(baseOf(repo.root)).toBe('main');
107
+ });
108
+ test('R3.1 an explicit base never falls back to origin/main', () => {
109
+ repo.git('update-ref', 'refs/remotes/origin/main', 'main');
110
+ expect(baseOf(repo.root, 'nope')).toBeNull();
111
+ });
79
112
  test('R3.1 an explicit base wins over origin/HEAD and main', () => {
80
113
  branch();
81
114
  repo.git('branch', 'release');
@@ -89,6 +122,55 @@ describe('git functions in a repository', () => {
89
122
  repo.git('branch', '-m', 'main', 'trunk');
90
123
  expect(baseOf(repo.root)).toBe('master');
91
124
  });
125
+ test.each(['main', 'master'])('R3.1 (Q10) a tag named %s does not redirect the local default, and the base keeps its short name', (name) => {
126
+ branch();
127
+ if (name === 'master')
128
+ repo.git('branch', '-m', 'main', 'master');
129
+ const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
130
+ repo.git('tag', name, tagged);
131
+ expect(repo.git('rev-parse', name)).toBe(tagged); // git alone prefers the tag
132
+ expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: name, sha: repo.git('rev-parse', `refs/heads/${name}`) });
133
+ });
134
+ test('R3.1 a tag literally named refs/heads/main is not the absent branch main: the base is master', () => {
135
+ branch();
136
+ repo.git('branch', '-m', 'main', 'master');
137
+ const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
138
+ repo.git('tag', 'refs/heads/main', tagged);
139
+ expect(repo.git('rev-parse', 'refs/heads/main')).toBe(tagged); // git alone falls through to refs/tags/refs/heads/main
140
+ expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: 'master', sha: repo.git('rev-parse', 'refs/heads/master') });
141
+ });
142
+ test('R3.1 a tag literally named refs/remotes/origin/main is not the absent remote branch: the base is main', () => {
143
+ branch();
144
+ const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
145
+ repo.git('tag', 'refs/remotes/origin/main', tagged);
146
+ expect(repo.git('rev-parse', 'refs/remotes/origin/main')).toBe(tagged);
147
+ expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: 'main', sha: repo.git('rev-parse', 'refs/heads/main') });
148
+ });
149
+ test('R3.1 only an exact refname counts: a branch main/x does not make refs/heads/main exist (a tag of that name is not it)', () => {
150
+ branch();
151
+ repo.git('branch', '-m', 'main', 'master');
152
+ repo.git('branch', 'main/x', 'master'); // `for-each-ref refs/heads/main` lists refs/heads/main/x
153
+ const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
154
+ repo.git('tag', 'refs/heads/main', tagged);
155
+ expect(repo.git('for-each-ref', '--format=%(refname)', 'refs/heads/main')).toBe('refs/heads/main/x');
156
+ expect((0, git_1.resolveBaseCommit)(repo.root)).toEqual({ base: 'master', sha: repo.git('rev-parse', 'refs/heads/master') });
157
+ });
158
+ test('R3.1 when git cannot list the default refs, no default is taken (fail closed)', () => {
159
+ const git = (0, helpers_1.spyOnGit)((args) => { if (args.includes('for-each-ref'))
160
+ throw new Error('boom'); });
161
+ try {
162
+ expect((0, git_1.resolveBaseCommit)(repo.root)).toBeNull();
163
+ }
164
+ finally {
165
+ git.restore();
166
+ }
167
+ });
168
+ test('R3.1 an explicit base keeps git\'s own resolution (a tag counts)', () => {
169
+ branch();
170
+ const tagged = repo.commit({ 'tagged.ts': '1' }, 'feat: tagged');
171
+ repo.git('tag', 'v1', tagged);
172
+ expect((0, git_1.resolveBaseCommit)(repo.root, 'v1')).toEqual({ base: 'v1', sha: tagged });
173
+ });
92
174
  test('R3.1 origin/HEAD that points nowhere is skipped', () => {
93
175
  repo.git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/gone');
94
176
  expect(baseOf(repo.root)).toBe('main');
@@ -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
- /** `test` where symlinks and POSIX modes exist; reported as skipped (not as passed) on Windows. */
139
- exports.posixOnly = process.platform === 'win32' ? test.skip : test;
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;
@@ -28,14 +28,15 @@ describe('listChanges: input and base (R5.1, R3.1, R3.2, R3.5)', () => {
28
28
  adopt();
29
29
  expect(listed().base).toBe('main');
30
30
  });
31
- test('the default base costs one rev-parse per candidate tried, not a second one to pin it', () => {
31
+ test('the default base costs one for-each-ref for all candidates and one rev-parse per existing candidate, not a second one to pin it', () => {
32
32
  adopt();
33
33
  const spy = (0, helpers_1.spyOnGit)();
34
34
  listed();
35
35
  spy.restore();
36
36
  const asked = (ref) => spy.calls.filter((a) => a.includes(`${ref}^{commit}`)).length;
37
- expect(asked('refs/remotes/origin/HEAD')).toBe(1);
38
- expect(asked('main')).toBe(1);
37
+ expect(spy.calls.filter((a) => a.includes('for-each-ref') && a.includes('refs/heads/main'))).toHaveLength(1);
38
+ expect(asked('refs/remotes/origin/HEAD')).toBe(0); // absent: never handed to rev-parse
39
+ expect(asked('refs/heads/main')).toBe(1); // the local default resolves as the full ref (Q10)
39
40
  });
40
41
  test('an explicit base is used and reported', () => {
41
42
  adopt();
@@ -568,7 +569,7 @@ describe('listChanges: derived fields (R5.5)', () => {
568
569
  const spy = (0, helpers_1.spyOnGit)();
569
570
  listed();
570
571
  spy.restore();
571
- const lookups = spy.calls.filter((a) => a.includes('for-each-ref'));
572
+ const lookups = spy.calls.filter((a) => a.includes('for-each-ref') && a.includes('refs/tags/')); // the tag lookup, not the base's
572
573
  expect(lookups).toHaveLength(1);
573
574
  expect(lookups[0]).toContain('--count=500');
574
575
  });
@@ -610,7 +611,7 @@ describe('listChanges: derived fields (R5.5)', () => {
610
611
  test('a git failure while looking for the tags is git-failed, never a release of null', () => {
611
612
  adopt();
612
613
  repo.commit({ 'a.ts': '1' }, 'fix: a', '2026-10-16T10:00:00Z');
613
- const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('for-each-ref'))
614
+ const spy = (0, helpers_1.spyOnGit)((args) => { if (args.includes('for-each-ref') && args.includes('refs/tags/'))
614
615
  throw new Error('boom'); });
615
616
  const outcome = list();
616
617
  spy.restore();
@@ -1,7 +1,13 @@
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 fs_1 = __importDefault(require("fs"));
7
+ const path_1 = __importDefault(require("path"));
3
8
  const helpers_1 = require("./helpers");
4
9
  const command_1 = require("../../../src/core/changes/command");
10
+ const git_1 = require("../../../src/core/changes/git");
5
11
  const range_1 = require("../../../src/core/changes/range");
6
12
  let repo;
7
13
  /** A repository for the groups that need one (the pure recordDirProblem tests need none). */
@@ -40,11 +46,94 @@ describe('resolveRange', () => {
40
46
  expect((0, range_1.resolveRange)(repo.root, 'main')).toMatchObject({ ok: false, failure: { code: 'base-unresolved', remedy: 'pass --base <ref>' } });
41
47
  expect((0, range_1.resolveRange)(repo.root, 'main').failure.message).toContain('no merge base');
42
48
  });
49
+ /** A commit on its own branch from main, made shallow (no parents as far as git can tell): it shares no merge base with feat/x. */
50
+ function shallowCommit() {
51
+ repo.git('checkout', '-q', '-b', 'up', 'main');
52
+ const up = repo.commit({ 'up.ts': '1' }, 'feat: upstream');
53
+ repo.git('checkout', '-q', 'feat/x');
54
+ repo.git('branch', '-D', 'up');
55
+ fs_1.default.writeFileSync(path_1.default.join(repo.root, '.git', 'shallow'), `${up}\n`);
56
+ return up;
57
+ }
58
+ test('R3.1 (Q7) without --base, a shallow origin/main with no merge base gives way to a connected local main', () => {
59
+ const main = (0, helpers_1.branchOff)(repo);
60
+ const head = repo.commit({ 'a.ts': '1' }, 'feat: a');
61
+ const up = shallowCommit();
62
+ repo.git('update-ref', 'refs/remotes/origin/main', up);
63
+ expect((0, range_1.resolveRange)(repo.root, undefined)).toEqual({ ok: true, head, mergeBase: main });
64
+ // `changes list` needs no merge base: it keeps the first candidate that names a commit.
65
+ expect((0, range_1.resolveBaseRef)(repo.root, undefined)).toEqual({ ok: true, base: 'refs/remotes/origin/main', sha: up });
66
+ });
67
+ test('R3.1 (Q7) an explicit --base without a merge base is base-unresolved: no fallback to the defaults', () => {
68
+ (0, helpers_1.branchOff)(repo);
69
+ repo.commit({ 'a.ts': '1' }, 'feat: a');
70
+ repo.git('branch', 'shallow', shallowCommit());
71
+ expect((0, range_1.resolveRange)(repo.root, 'shallow')).toEqual({ ok: false, failure: { code: 'base-unresolved', message: 'no merge base between "shallow" and HEAD', remedy: 'pass --base <ref>' } });
72
+ });
73
+ test('R3.1 (Q7) without --base, when no default that names a commit has a merge base, the failure names the first of them', () => {
74
+ (0, helpers_1.branchOff)(repo);
75
+ repo.commit({ 'a.ts': '1' }, 'feat: a');
76
+ const up = shallowCommit();
77
+ repo.git('update-ref', 'refs/remotes/origin/main', up);
78
+ repo.git('update-ref', 'refs/heads/main', up);
79
+ expect((0, range_1.resolveRange)(repo.root, undefined)).toEqual({ ok: false, failure: { code: 'base-unresolved', message: 'no merge base between "refs/remotes/origin/main" and HEAD', remedy: 'pass --base <ref>' } });
80
+ });
81
+ test('R3.1 without --base, a tag literally named refs/heads/main is not taken for the absent branch main', () => {
82
+ (0, helpers_1.branchOff)(repo);
83
+ const head = repo.commit({ 'a.ts': '1' }, 'feat: a');
84
+ const master = repo.git('rev-parse', 'main');
85
+ repo.git('branch', '-m', 'main', 'master');
86
+ repo.git('tag', 'refs/heads/main', head);
87
+ expect((0, range_1.resolveRange)(repo.root, undefined)).toEqual({ ok: true, head, mergeBase: master });
88
+ });
89
+ test('R3.1 (Q9) the merge base comes from the commit id the base resolved to, not from the base name read again', () => {
90
+ const main = (0, helpers_1.branchOff)(repo);
91
+ const head = repo.commit({ 'a.ts': '1' }, 'feat: a');
92
+ const elsewhere = shallowCommit();
93
+ let moved = false;
94
+ const git = (0, helpers_1.spyOnGit)((args) => {
95
+ if (!moved && args[0] === 'merge-base') {
96
+ moved = true;
97
+ repo.git('update-ref', 'refs/heads/main', elsewhere);
98
+ }
99
+ });
100
+ try {
101
+ expect((0, range_1.resolveRange)(repo.root, undefined)).toEqual({ ok: true, head, mergeBase: main });
102
+ }
103
+ finally {
104
+ git.restore();
105
+ }
106
+ expect(moved).toBe(true);
107
+ });
43
108
  test('an unreadable HEAD is git-failed', () => {
44
109
  expect((0, range_1.resolveRange)((0, helpers_1.nonRepoDir)(repo), undefined)).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
45
110
  expect((0, range_1.resolveRange)((0, helpers_1.bad)(undefined), undefined)).toMatchObject({ ok: false, failure: { code: 'git-failed' } });
46
111
  });
47
112
  });
113
+ describe('BASE_OPTION_HELP (R3.1, Q6)', () => {
114
+ test('names the default candidates in order', () => {
115
+ expect(range_1.BASE_OPTION_HELP).toBe('base branch (default: origin/HEAD, origin/main, origin/master, main, master)');
116
+ });
117
+ test('is built from the candidate list, not written by hand', () => {
118
+ jest.isolateModules(() => {
119
+ jest.doMock('../../../src/core/changes/git', () => ({
120
+ ...jest.requireActual('../../../src/core/changes/git'),
121
+ DEFAULT_BASES: [{ ref: 'refs/remotes/origin/trunk', name: 'refs/remotes/origin/trunk' }, { ref: 'refs/heads/dev', name: 'dev' }],
122
+ }));
123
+ expect(require('../../../src/core/changes/range').BASE_OPTION_HELP).toBe('base branch (default: origin/trunk, dev)');
124
+ });
125
+ jest.dontMock('../../../src/core/changes/git');
126
+ });
127
+ test('the default candidates are the remote ones, then the local branches as full refs that report their short name', () => {
128
+ expect(git_1.DEFAULT_BASES).toEqual([
129
+ { ref: 'refs/remotes/origin/HEAD', name: 'refs/remotes/origin/HEAD' },
130
+ { ref: 'refs/remotes/origin/main', name: 'refs/remotes/origin/main' },
131
+ { ref: 'refs/remotes/origin/master', name: 'refs/remotes/origin/master' },
132
+ { ref: 'refs/heads/main', name: 'main' },
133
+ { ref: 'refs/heads/master', name: 'master' },
134
+ ]);
135
+ });
136
+ });
48
137
  describe('headMovedFailure', () => {
49
138
  withRepo();
50
139
  test('HEAD moving has its own code, and it is an operational failure (exit 2) like the others', () => {
@@ -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
- it('uses mode 0600 for a POSIX adjacent temporary file', () => {
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
- test('rejects symlinked evidence ancestors without writing outside root', () => {
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
- test('refuses a dangling symlink archive destination and preserves the active ledger', () => {
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
- try {
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'),