@marcomigozzi/trailhead 0.11.1 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.it.md +6 -3
  2. package/README.md +6 -3
  3. package/bin/lib/codex-projection.js +2 -1
  4. package/bin/lib/codex-projection.test.js +4 -1
  5. package/bin/trailhead.js +23 -13
  6. package/bin/trailhead.test.js +328 -0
  7. package/package.json +1 -1
  8. package/plugins/trailhead/.claude-plugin/plugin.json +1 -1
  9. package/plugins/trailhead/commands/adopt.md +5 -1
  10. package/plugins/trailhead/commands/audit.md +10 -0
  11. package/plugins/trailhead/commands/auto.md +5 -1
  12. package/plugins/trailhead/commands/bug.md +5 -1
  13. package/plugins/trailhead/commands/config.md +5 -1
  14. package/plugins/trailhead/commands/dashboard.md +5 -1
  15. package/plugins/trailhead/commands/grill.md +5 -1
  16. package/plugins/trailhead/commands/idea.md +5 -1
  17. package/plugins/trailhead/commands/inbox.md +5 -1
  18. package/plugins/trailhead/commands/map.md +5 -1
  19. package/plugins/trailhead/commands/new.md +5 -1
  20. package/plugins/trailhead/commands/note.md +5 -1
  21. package/plugins/trailhead/commands/pause.md +5 -1
  22. package/plugins/trailhead/commands/prune.md +5 -1
  23. package/plugins/trailhead/commands/quick.md +6 -2
  24. package/plugins/trailhead/commands/resume.md +5 -1
  25. package/plugins/trailhead/commands/seed.md +5 -1
  26. package/plugins/trailhead/commands/split.md +5 -1
  27. package/plugins/trailhead/commands/ticket.md +5 -1
  28. package/plugins/trailhead/commands/todo.md +5 -1
  29. package/plugins/trailhead/commands/update.md +5 -1
  30. package/plugins/trailhead/commands/whiteboard.md +5 -1
  31. package/plugins/trailhead/commands/work.md +5 -1
  32. package/plugins/trailhead/hooks/hooks.json +9 -0
  33. package/plugins/trailhead/hooks/lib/audit-walk.test.js +802 -0
  34. package/plugins/trailhead/hooks/lib/fixtures/label-guard-0.12.0.yml +55 -0
  35. package/plugins/trailhead/hooks/lib/label-guard-sync.test.js +156 -0
  36. package/plugins/trailhead/hooks/lib/mockup-link.js +50 -0
  37. package/plugins/trailhead/hooks/lib/model-defaults-review.test.js +56 -4
  38. package/plugins/trailhead/hooks/lib/model-defaults.json +5 -1
  39. package/plugins/trailhead/hooks/trailhead-check-update.js +25 -1
  40. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.js +95 -0
  41. package/plugins/trailhead/hooks/trailhead-mockup-link-guard.test.js +145 -0
  42. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.js +38 -0
  43. package/plugins/trailhead/hooks/trailhead-mockup-link-stop.test.js +64 -0
  44. package/plugins/trailhead/hooks/trailhead-secret-read-guard.js +151 -6
  45. package/plugins/trailhead/hooks/trailhead-secret-read-guard.test.js +56 -0
  46. package/plugins/trailhead/skills/_shared/choices.md +2 -0
  47. package/plugins/trailhead/skills/_shared/configuration-reference.md +3 -3
  48. package/plugins/trailhead/skills/_shared/load-first.md +2 -0
  49. package/plugins/trailhead/skills/_shared/principles.md +1 -1
  50. package/plugins/trailhead/skills/_shared/session-handoff.md +1 -1
  51. package/plugins/trailhead/skills/_shared/substrate-commands.md +9 -2
  52. package/plugins/trailhead/skills/_shared/teamwork.md +3 -1
  53. package/plugins/trailhead/skills/_shared/techniques/prototype.md +1 -1
  54. package/plugins/trailhead/skills/trailhead/SKILL.md +2 -2
  55. package/plugins/trailhead/skills/trailhead-capture/references/capture.md +1 -1
  56. package/plugins/trailhead/skills/trailhead-manage/SKILL.md +5 -4
  57. package/plugins/trailhead/skills/trailhead-manage/references/auditing.md +132 -0
  58. package/plugins/trailhead/skills/trailhead-manage/references/updating.md +4 -0
  59. package/plugins/trailhead/skills/trailhead-view/SKILL.md +1 -0
  60. package/plugins/trailhead/skills/trailhead-work/SKILL.md +1 -1
  61. package/plugins/trailhead/templates/trailhead-audit-walk.js +685 -0
  62. package/plugins/trailhead/templates/trailhead-label-guard-sync.js +212 -0
  63. package/plugins/trailhead/templates/trailhead-label-guard.yml +5 -0
@@ -0,0 +1,802 @@
1
+ #!/usr/bin/env node
2
+ // Tests for templates/trailhead-audit-walk.js. Run: node audit-walk.test.js
3
+ // No framework: plain asserts, mirrors the style of commit-msg-sync.test.js.
4
+ //
5
+ // Layers:
6
+ // 1. parseRefs() / parseHunks() / hunkTouches() pure unit cases.
7
+ // 2. fastPath() against synthetic git repos built in os.tmpdir().
8
+ // 3. attribute() (the forward walk) against the same kind of repos.
9
+ // 4. End-to-end: execFileSync the script (index -> fastpath -> attribute).
10
+ const assert = require('assert');
11
+ const { execFileSync } = require('child_process');
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+
16
+ const scriptPath = path.resolve(__dirname, '..', '..', 'templates', 'trailhead-audit-walk.js');
17
+ const walk = require(scriptPath);
18
+ const { parseRefs, parseHunks, hunkTouches } = walk;
19
+
20
+ let passed = 0;
21
+ const ok = (name, cond) => { assert.ok(cond, name); passed++; };
22
+ const eq = (name, actual, expected) => {
23
+ assert.deepStrictEqual(actual, expected, `${name}: got ${JSON.stringify(actual)}`);
24
+ passed++;
25
+ };
26
+
27
+ // --- synthetic repo harness --------------------------------------------------
28
+ // LC_ALL=C keeps git's output stable on localized machines; hooks and signing
29
+ // are off so a developer's global config cannot change the outcome.
30
+ const GIT_ENV = { ...process.env, LC_ALL: 'C', GIT_CONFIG_NOSYSTEM: '1' };
31
+ const GIT_FLAGS = [
32
+ '-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false',
33
+ '-c', 'user.name=Audit Test', '-c', 'user.email=audit@example.com',
34
+ ];
35
+ const tmpDirs = [];
36
+ let clock = 0;
37
+
38
+ function git(dir, args, env) {
39
+ return execFileSync('git', ['-C', dir, ...GIT_FLAGS, ...args], {
40
+ encoding: 'utf8', env: { ...GIT_ENV, ...(env || {}) }, stdio: ['ignore', 'pipe', 'pipe'],
41
+ });
42
+ }
43
+
44
+ function mkrepo() {
45
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-audit-walk-test-'));
46
+ tmpDirs.push(dir);
47
+ git(dir, ['init', '-q', '-b', 'main']);
48
+ return dir;
49
+ }
50
+
51
+ // Monotonic fake clock, so ancestry (not timestamps) is what the tests rely on
52
+ // unless a case overrides the date on purpose.
53
+ function nextDate() {
54
+ clock += 60;
55
+ return new Date(Date.UTC(2021, 0, 1, 0, 0, 0) + clock * 1000).toISOString();
56
+ }
57
+
58
+ // files: { path: content | null }; null removes the path. opts.refs adds a
59
+ // real `Refs:` trailer; opts.body is raw prose placed before it.
60
+ function commit(dir, files, subject, opts = {}) {
61
+ for (const [rel, content] of Object.entries(files)) {
62
+ const abs = path.join(dir, rel);
63
+ if (content === null) {
64
+ fs.rmSync(abs, { force: true });
65
+ } else {
66
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
67
+ fs.writeFileSync(abs, content);
68
+ }
69
+ }
70
+ git(dir, ['add', '-A']);
71
+ return finishCommit(dir, ['commit', '-q', '--allow-empty'], subject, opts);
72
+ }
73
+
74
+ function message(subject, opts) {
75
+ const parts = [subject];
76
+ if (opts.body) parts.push(opts.body);
77
+ if (opts.refs) parts.push('Refs: ' + opts.refs.map((n) => '#' + n).join(', '));
78
+ return parts.join('\n\n');
79
+ }
80
+
81
+ function finishCommit(dir, args, subject, opts) {
82
+ const date = opts.date || nextDate();
83
+ git(dir, [...args, '-m', message(subject, opts)], { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date });
84
+ return head(dir);
85
+ }
86
+
87
+ function head(dir) { return git(dir, ['rev-parse', 'HEAD']).trim(); }
88
+
89
+ // A numbered text file: lines('a', 5) -> "a1\na2\na3\na4\na5\n".
90
+ function lines(prefix, count) {
91
+ let out = '';
92
+ for (let i = 1; i <= count; i++) out += `${prefix}${i}\n`;
93
+ return out;
94
+ }
95
+
96
+ function cleanup() {
97
+ for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true });
98
+ }
99
+
100
+ // --- parseRefs() ---------------------------------------------------------------
101
+ eq('parseRefs: single token', parseRefs('#17'), [17]);
102
+ eq('parseRefs: comma + space list', parseRefs('#17, #192'), [17, 192]);
103
+ eq('parseRefs: separator-only comma list', parseRefs('#1,#2'), [1, 2]);
104
+ eq('parseRefs: #19 never matches inside #192', parseRefs('#192').includes(19), false);
105
+ eq('parseRefs: glued prefix is not a token', parseRefs('foo#12'), []);
106
+ eq('parseRefs: glued suffix is not a token', parseRefs('#12x'), []);
107
+ eq('parseRefs: empty value', parseRefs(''), []);
108
+ eq('parseRefs: null value', parseRefs(null), []);
109
+ eq('parseRefs: duplicates collapse', parseRefs('#5 #5'), [5]);
110
+ eq('parseRefs: whitespace delimited with a Refs: prefix', parseRefs('Refs: #5 and #6'), [5, 6]);
111
+
112
+ // --- parseHunks() --------------------------------------------------------------
113
+ {
114
+ const diff = [
115
+ 'diff --git a/f b/f', 'index 111..222 100644', '--- a/f', '+++ b/f',
116
+ '@@ -3 +3 @@ some context', '-old', '+new',
117
+ '@@ -10,2 +10,3 @@', '-a', '-b', '+x', '+y', '+z',
118
+ '@@ -20,0 +24,2 @@', '+ins1', '+ins2',
119
+ '@@ -30,4 +36,0 @@', '-d1', '-d2', '-d3', '-d4',
120
+ '',
121
+ ].join('\n');
122
+ eq('parseHunks: defaults, counts, insertions and deletions', parseHunks(diff), [
123
+ { oldStart: 3, oldLen: 1, newStart: 3, newLen: 1 },
124
+ { oldStart: 10, oldLen: 2, newStart: 10, newLen: 3 },
125
+ { oldStart: 20, oldLen: 0, newStart: 24, newLen: 2 },
126
+ { oldStart: 30, oldLen: 4, newStart: 36, newLen: 0 },
127
+ ]);
128
+ eq('parseHunks: no hunk (binary or mode-only)', parseHunks('Binary files a/x and b/x differ\n'), []);
129
+ eq('parseHunks: a content line that looks like a header is ignored',
130
+ parseHunks('@@ -1 +1 @@\n-x\n+@@ -9,9 +9,9 @@\n'), [{ oldStart: 1, oldLen: 1, newStart: 1, newLen: 1 }]);
131
+ }
132
+
133
+ // --- hunkTouches() ---------------------------------------------------------------
134
+ {
135
+ const replace = { oldStart: 5, oldLen: 2, newStart: 5, newLen: 3 }; // old lines 5-6
136
+ ok('hunkTouches: replaced tracked line', hunkTouches(replace, [6], []));
137
+ ok('hunkTouches: tracked line outside the range', !hunkTouches(replace, [7], []));
138
+ ok('hunkTouches: anchor whose g+1 is covered', hunkTouches(replace, [], [4]));
139
+ ok('hunkTouches: anchor whose g is covered', hunkTouches(replace, [], [6]));
140
+ ok('hunkTouches: anchor beyond the range', !hunkTouches(replace, [], [7]));
141
+ ok('hunkTouches: anchor before the range', !hunkTouches(replace, [], [3]));
142
+ const insertion = { oldStart: 5, oldLen: 0, newStart: 6, newLen: 2 }; // gap after old line 5
143
+ ok('hunkTouches: insertion with b tracked', hunkTouches(insertion, [5], []));
144
+ ok('hunkTouches: insertion with b+1 tracked', hunkTouches(insertion, [6], []));
145
+ ok('hunkTouches: insertion away from tracked lines', !hunkTouches(insertion, [4, 7], []));
146
+ ok('hunkTouches: insertion at an anchor gap', hunkTouches(insertion, [], [5]));
147
+ ok('hunkTouches: insertion next to, not at, an anchor', !hunkTouches(insertion, [], [4, 6]));
148
+ const deletion = { oldStart: 8, oldLen: 3, newStart: 7, newLen: 0 }; // old lines 8-10
149
+ ok('hunkTouches: pure deletion covering a tracked line', hunkTouches(deletion, [9], []));
150
+ ok('hunkTouches: pure deletion next to a tracked line', !hunkTouches(deletion, [7, 11], []));
151
+ ok('hunkTouches: accepts Sets', hunkTouches(deletion, new Set([10]), new Set()));
152
+ }
153
+
154
+ // --- fast path ---------------------------------------------------------------
155
+ const { buildTrailerIndex, fastPath } = walk;
156
+
157
+ // 1-based splice on a text file: splice(text, 3, 2, 'x') replaces lines 3-4.
158
+ function splice(text, start, del, ...ins) {
159
+ const arr = text.replace(/\n$/, '').split('\n');
160
+ arr.splice(start - 1, del, ...ins);
161
+ return arr.join('\n') + '\n';
162
+ }
163
+
164
+ // A repo with f.txt (10 lines) and other.txt; returns { dir, f, base }.
165
+ function baseRepo() {
166
+ const dir = mkrepo();
167
+ const f = lines('a', 10);
168
+ const base = commit(dir, { 'f.txt': f, 'other.txt': lines('o', 3) }, 'chore: base');
169
+ return { dir, f, base };
170
+ }
171
+
172
+ function fp(dir, n) { return fastPath(dir, n, buildTrailerIndex(dir)); }
173
+
174
+ // Merge `branch` into the current branch; `resolved` (files) settles conflicts.
175
+ function mergeBranch(dir, branch, resolved, subject, opts = {}) {
176
+ try { git(dir, ['merge', '-q', '--no-ff', '--no-commit', branch]); } catch { /* conflict: resolved below */ }
177
+ for (const [rel, content] of Object.entries(resolved || {})) fs.writeFileSync(path.join(dir, rel), content);
178
+ git(dir, ['add', '-A']);
179
+ return finishCommit(dir, ['commit', '-q', '--allow-empty'], subject, opts);
180
+ }
181
+
182
+ // untouched ticket code is unchanged
183
+ {
184
+ const { dir, f } = baseRepo();
185
+ const c2 = commit(dir, { 'f.txt': splice(f, 3, 2, 'T3', 'T4') }, 'feat: ticket', { refs: [1] });
186
+ commit(dir, { 'other.txt': lines('p', 3) }, 'chore: elsewhere');
187
+ const r = fp(dir, 1);
188
+ eq('fastPath untouched: class and reason', [r.class, r.reason], ['unchanged', null]);
189
+ eq('fastPath untouched: last, commits, files', [r.last, r.commits, r.files], [c2, [c2], ['f.txt']]);
190
+ eq('fastPath untouched: evolvedEligible', r.evolvedEligible, true);
191
+ eq('fastPath untouched: ticket field', r.ticket, 1);
192
+ }
193
+
194
+ // an edit of a ticket line is changed
195
+ {
196
+ const { dir, f } = baseRepo();
197
+ const t = splice(f, 3, 2, 'T3', 'T4');
198
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
199
+ commit(dir, { 'f.txt': splice(t, 3, 1, 'X3') }, 'fix: later edit');
200
+ const r = fp(dir, 1);
201
+ eq('fastPath edited line: changed/touching-hunk', [r.class, r.reason], ['changed', 'touching-hunk']);
202
+ eq('fastPath edited line: still evolved-eligible', r.evolvedEligible, true);
203
+ }
204
+
205
+ // an edit elsewhere in the same file is unchanged
206
+ {
207
+ const { dir, f } = baseRepo();
208
+ const t = splice(f, 3, 2, 'T3', 'T4');
209
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
210
+ commit(dir, { 'f.txt': splice(t, 9, 1, 'X9') }, 'fix: far edit');
211
+ eq('fastPath edit elsewhere: unchanged', fp(dir, 1).class, 'unchanged');
212
+ }
213
+
214
+ // pure deletion: the anchor decides
215
+ {
216
+ const touched = baseRepo();
217
+ const t = splice(touched.f, 5, 2); // ticket deletes a5-a6 -> gap after line 4
218
+ commit(touched.dir, { 'f.txt': t }, 'feat: delete', { refs: [1] });
219
+ commit(touched.dir, { 'f.txt': splice(t, 4, 1, 'X4') }, 'fix: edit at the anchor');
220
+ const r = fp(touched.dir, 1);
221
+ eq('fastPath deletion anchor touched: changed/touching-hunk', [r.class, r.reason], ['changed', 'touching-hunk']);
222
+
223
+ const gapEdge = baseRepo();
224
+ const t2 = splice(gapEdge.f, 5, 2);
225
+ commit(gapEdge.dir, { 'f.txt': t2 }, 'feat: delete', { refs: [1] });
226
+ commit(gapEdge.dir, { 'f.txt': splice(t2, 5, 1, 'X5') }, 'fix: edit after the anchor');
227
+ eq('fastPath deletion anchor edge (g+1): changed', fp(gapEdge.dir, 1).reason, 'touching-hunk');
228
+
229
+ const untouched = baseRepo();
230
+ const t3 = splice(untouched.f, 5, 2);
231
+ commit(untouched.dir, { 'f.txt': t3 }, 'feat: delete', { refs: [1] });
232
+ commit(untouched.dir, { 'f.txt': splice(t3, 8, 1, 'X8') }, 'fix: edit away from the anchor');
233
+ eq('fastPath deletion anchor untouched: unchanged', fp(untouched.dir, 1).class, 'unchanged');
234
+ }
235
+
236
+ // insertion inside, at the edge of, and away from the ticket span
237
+ {
238
+ const mk = (insertAfter) => {
239
+ const { dir, f } = baseRepo();
240
+ const t = splice(f, 3, 4, 'T3', 'T4', 'T5', 'T6'); // ticket owns lines 3-6
241
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
242
+ commit(dir, { 'f.txt': splice(t, insertAfter + 1, 0, 'NEW') }, 'fix: insert');
243
+ return fp(dir, 1);
244
+ };
245
+ eq('fastPath insertion inside the span: changed', mk(4).reason, 'touching-hunk');
246
+ eq('fastPath insertion at the edge of the span: changed', mk(6).reason, 'touching-hunk');
247
+ eq('fastPath insertion far from the span: unchanged', mk(9).class, 'unchanged');
248
+ }
249
+
250
+ // rename or delete since is deleted-or-renamed
251
+ {
252
+ const { dir, f } = baseRepo();
253
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'T3') }, 'feat: ticket', { refs: [1] });
254
+ git(dir, ['mv', 'f.txt', 'g.txt']);
255
+ finishCommit(dir, ['commit', '-q'], 'refactor: rename', {});
256
+ const r = fp(dir, 1);
257
+ eq('fastPath rename since: deleted-or-renamed', [r.class, r.reason], ['changed', 'deleted-or-renamed']);
258
+ }
259
+ {
260
+ const { dir, f } = baseRepo();
261
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'T3') }, 'feat: ticket', { refs: [1] });
262
+ commit(dir, { 'f.txt': null }, 'chore: drop file');
263
+ eq('fastPath delete since: deleted-or-renamed', fp(dir, 1).reason, 'deleted-or-renamed');
264
+ }
265
+
266
+ // a file the ticket deleted counts only if it exists again at HEAD
267
+ {
268
+ const { dir } = baseRepo();
269
+ commit(dir, { 'f.txt': null }, 'chore: ticket deletes', { refs: [1] });
270
+ eq('fastPath ticket-deleted file stays gone: unchanged', fp(dir, 1).class, 'unchanged');
271
+ commit(dir, { 'f.txt': 'again\n' }, 'feat: recreated');
272
+ const r = fp(dir, 1);
273
+ eq('fastPath ticket-deleted file recreated: changed/recreated', [r.class, r.reason], ['changed', 'recreated']);
274
+ }
275
+
276
+ // a file that differs without a textual hunk is non-textual (mode-only, binary)
277
+ {
278
+ const { dir, f } = baseRepo();
279
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'T3') }, 'feat: ticket', { refs: [1] });
280
+ fs.chmodSync(path.join(dir, 'f.txt'), 0o755);
281
+ commit(dir, {}, 'chore: chmod');
282
+ const r = fp(dir, 1);
283
+ eq('fastPath mode-only change: non-textual', [r.class, r.reason], ['changed', 'non-textual']);
284
+ }
285
+ {
286
+ const dir = mkrepo();
287
+ commit(dir, { 'img.bin': Buffer.from([0, 1, 2, 3, 0, 4]).toString('latin1') }, 'chore: base');
288
+ fs.writeFileSync(path.join(dir, 'img.bin'), Buffer.from([0, 9, 9, 9, 0, 4]));
289
+ commit(dir, {}, 'feat: ticket binary', { refs: [1] });
290
+ fs.writeFileSync(path.join(dir, 'img.bin'), Buffer.from([0, 7, 7, 7, 0, 4]));
291
+ commit(dir, {}, 'fix: binary changes');
292
+ eq('fastPath binary change: non-textual', fp(dir, 1).reason, 'non-textual');
293
+ }
294
+
295
+ // an empty file at <last> that differs is changed
296
+ {
297
+ const { dir } = baseRepo();
298
+ commit(dir, { 'e.txt': '' }, 'feat: empty file', { refs: [1] });
299
+ eq('fastPath empty file unchanged', fp(dir, 1).class, 'unchanged');
300
+ commit(dir, { 'e.txt': 'now has content\n' }, 'feat: fills it');
301
+ eq('fastPath empty file that differs: touching-hunk', fp(dir, 1).reason, 'touching-hunk');
302
+ }
303
+
304
+ // no Refs commits -> no-refs
305
+ {
306
+ const { dir } = baseRepo();
307
+ const r = fp(dir, 99);
308
+ eq('fastPath no Refs: changed/no-refs', [r.class, r.reason], ['changed', 'no-refs']);
309
+ eq('fastPath no Refs: ineligible, empty commits', [r.evolvedEligible, r.commits, r.last], [false, [], null]);
310
+ }
311
+
312
+ // #19 never matches #192, prose Refs lines are ignored
313
+ {
314
+ const { dir, f } = baseRepo();
315
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'T3') }, 'feat: ticket 192', { refs: [17, 192], body: 'Refs: #19 is only prose here' });
316
+ eq('fastPath #19 does not match #192: no-refs', fp(dir, 19).reason, 'no-refs');
317
+ eq('fastPath #192 in a multi-ref trailer', fp(dir, 192).class, 'unchanged');
318
+ eq('fastPath #17 in the same trailer', fp(dir, 17).class, 'unchanged');
319
+ }
320
+
321
+ // non-linear: ticket commits on incomparable branches
322
+ {
323
+ const { dir, f } = baseRepo();
324
+ git(dir, ['checkout', '-q', '-b', 'side']);
325
+ commit(dir, { 'f.txt': splice(f, 2, 1, 'S2') }, 'feat: side part', { refs: [1] });
326
+ git(dir, ['checkout', '-q', 'main']);
327
+ commit(dir, { 'f.txt': splice(f, 8, 1, 'M8') }, 'feat: main part', { refs: [1] });
328
+ mergeBranch(dir, 'side', {}, 'merge: side');
329
+ const r = fp(dir, 1);
330
+ eq('fastPath non-linear: changed/non-linear', [r.class, r.reason, r.evolvedEligible], ['changed', 'non-linear', false]);
331
+ }
332
+
333
+ // ticket commits ordered by ancestry, never by timestamp
334
+ {
335
+ const { dir, f } = baseRepo();
336
+ const t1 = splice(f, 2, 1, 'T2');
337
+ const c2 = commit(dir, { 'f.txt': t1 }, 'feat: first', { refs: [1], date: '2030-01-01T00:00:00Z' });
338
+ const c3 = commit(dir, { 'f.txt': splice(t1, 8, 1, 'T8') }, 'feat: second', { refs: [1], date: '2001-01-01T00:00:00Z' });
339
+ const r = fp(dir, 1);
340
+ eq('fastPath out-of-order dates: ancestry picks last', [r.last, r.commits, r.class], [c3, [c2, c3], 'unchanged']);
341
+ }
342
+
343
+ // guard: a non-ticket commit on the chain touching a ticket file
344
+ {
345
+ const { dir, f } = baseRepo();
346
+ const t = splice(f, 2, 1, 'T2');
347
+ commit(dir, { 'f.txt': t }, 'feat: first', { refs: [1] });
348
+ const mid = splice(t, 9, 1, 'X9');
349
+ commit(dir, { 'f.txt': mid }, 'fix: foreign edit in the same file');
350
+ commit(dir, { 'f.txt': splice(mid, 5, 1, 'T5') }, 'feat: second', { refs: [1] });
351
+ const r = fp(dir, 1);
352
+ eq('fastPath guard on the chain: changed/guard', [r.class, r.reason, r.evolvedEligible], ['changed', 'guard', true]);
353
+ }
354
+ // a side branch that never joins the chain is not a guard hit
355
+ {
356
+ const { dir, f } = baseRepo();
357
+ const t = splice(f, 2, 1, 'T2');
358
+ commit(dir, { 'f.txt': t }, 'feat: first', { refs: [1] });
359
+ git(dir, ['checkout', '-q', '-b', 'side']);
360
+ commit(dir, { 'f.txt': splice(t, 9, 1, 'S9') }, 'fix: unmerged side edit');
361
+ git(dir, ['checkout', '-q', 'main']);
362
+ commit(dir, { 'f.txt': splice(t, 5, 1, 'T5') }, 'feat: second', { refs: [1] });
363
+ eq('fastPath unmerged side branch: not a guard hit', fp(dir, 1).class, 'unchanged');
364
+ }
365
+
366
+ // a later ticket commit around an earlier deletion keeps the anchor
367
+ {
368
+ const { dir, f } = baseRepo();
369
+ const t = splice(f, 5, 2); // a1-a4, a7-a10: anchor after line 4
370
+ commit(dir, { 'f.txt': t }, 'feat: delete', { refs: [1] });
371
+ const t2 = splice(t, 5, 1); // ticket deletes a7 too: pure deletion at the anchor edge
372
+ commit(dir, { 'f.txt': t2 }, 'feat: delete more', { refs: [1] });
373
+ eq('fastPath anchor survives a later ticket deletion: unchanged', fp(dir, 1).class, 'unchanged');
374
+ commit(dir, { 'f.txt': splice(t2, 4, 1, 'X4') }, 'fix: edit at the carried anchor');
375
+ eq('fastPath carried anchor still detects an edit', fp(dir, 1).reason, 'touching-hunk');
376
+ }
377
+ {
378
+ const { dir, f } = baseRepo();
379
+ const t = splice(f, 5, 2);
380
+ commit(dir, { 'f.txt': t }, 'feat: delete', { refs: [1] });
381
+ const t2 = splice(t, 5, 1);
382
+ commit(dir, { 'f.txt': t2 }, 'feat: delete more', { refs: [1] });
383
+ commit(dir, { 'f.txt': splice(t2, 7, 1, 'X7') }, 'fix: edit far away');
384
+ eq('fastPath carried anchor, edit far away: unchanged', fp(dir, 1).class, 'unchanged');
385
+ }
386
+
387
+ // a ticket commit that is itself a merge: files, hunks and blame use the chosen parent
388
+ {
389
+ const { dir, f } = baseRepo();
390
+ git(dir, ['checkout', '-q', '-b', 'side']);
391
+ commit(dir, { 'f.txt': splice(f, 5, 1, 'side5'), 's.txt': 'side file\n' }, 'feat: side work');
392
+ git(dir, ['checkout', '-q', 'main']);
393
+ const m1 = splice(f, 5, 1, 'main5');
394
+ commit(dir, { 'f.txt': m1 }, 'feat: main work');
395
+ const merged = splice(f, 5, 1, 'merged5');
396
+ const mc = mergeBranch(dir, 'side', { 'f.txt': merged }, 'merge: side, resolved', { refs: [1] });
397
+ let r = fp(dir, 1);
398
+ eq('fastPath ticket merge: last is the merge, files vs first parent',
399
+ [r.last, r.files, r.class], [mc, ['f.txt', 's.txt'], 'unchanged']);
400
+ commit(dir, { 'f.txt': splice(merged, 1, 1, 'X1') }, 'fix: far edit');
401
+ eq('fastPath ticket merge: far edit unchanged', fp(dir, 1).class, 'unchanged');
402
+ commit(dir, { 'f.txt': splice(splice(merged, 1, 1, 'X1'), 5, 1, 'X5') }, 'fix: edit the resolved line');
403
+ eq('fastPath ticket merge: edit of the resolved line', fp(dir, 1).reason, 'touching-hunk');
404
+ }
405
+
406
+ // a stale or malformed index is rejected, never silently used
407
+ {
408
+ const { dir, f } = baseRepo();
409
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'T3') }, 'feat: ticket', { refs: [1] });
410
+ const index = buildTrailerIndex(dir);
411
+ commit(dir, { 'other.txt': 'moved on\n' }, 'chore: moves HEAD');
412
+ assert.throws(() => fastPath(dir, 1, index), /stale/); passed++;
413
+ assert.throws(() => fastPath(dir, 1, { head: head(dir) }), /malformed/); passed++;
414
+ const fresh = buildTrailerIndex(dir);
415
+ eq('buildTrailerIndex: head, shallow, byTicket', [fresh.head, fresh.shallow, Object.keys(fresh.byTicket)], [head(dir), false, ['1']]);
416
+ eq('buildTrailerIndex: refsBySha lists only trailer commits', Object.values(fresh.refsBySha), [[1]]);
417
+ }
418
+
419
+ // dirty ticket files are reported without any effect on the class
420
+ {
421
+ const { dir, f } = baseRepo();
422
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'T3') }, 'feat: ticket', { refs: [1] });
423
+ fs.writeFileSync(path.join(dir, 'f.txt'), 'uncommitted\n');
424
+ const r = fp(dir, 1);
425
+ eq('fastPath dirty tree: class unaffected, file reported', [r.class, r.dirtyFiles], ['unchanged', ['f.txt']]);
426
+ }
427
+
428
+ // shallow clones send every ticket to the agent and are ineligible for evolved
429
+ {
430
+ const { dir, f } = baseRepo();
431
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'T3') }, 'feat: ticket', { refs: [1] });
432
+ const clone = path.join(os.tmpdir(), 'trailhead-audit-walk-shallow-' + process.pid);
433
+ tmpDirs.push(clone);
434
+ execFileSync('git', ['clone', '-q', '--depth', '1', '--no-local', 'file://' + dir, clone], { env: GIT_ENV, stdio: 'pipe' });
435
+ const index = buildTrailerIndex(clone);
436
+ const r = fastPath(clone, 1, index);
437
+ eq('fastPath shallow: changed/shallow, ineligible', [index.shallow, r.class, r.reason, r.evolvedEligible], [true, 'changed', 'shallow', false]);
438
+ }
439
+
440
+ // --- attribution walk ----------------------------------------------------------
441
+ const { attribute } = walk;
442
+
443
+ function att(dir, n, specs) { return attribute(dir, n, specs, buildTrailerIndex(dir)); }
444
+ function shas(claim) { return claim.producers.map((p) => p.sha).sort(); }
445
+
446
+ // edit by a #m commit -> attributed, evolvedBy m
447
+ {
448
+ const { dir, f } = baseRepo();
449
+ const t = splice(f, 3, 2, 'T3', 'T4');
450
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
451
+ const c3 = commit(dir, { 'f.txt': splice(t, 3, 1, 'M3') }, 'feat: later ticket', { refs: [2] });
452
+ const r = att(dir, 1, ['f.txt:3-4', 'f.txt:9-10']);
453
+ eq('attribute edit by #2: eligible', [r.ticket, r.eligible, r.reason], [1, true, null]);
454
+ eq('attribute edit by #2: claim 3-4', r.claims[0], {
455
+ spec: 'f.txt:3-4', attributed: true, evolvedBy: [2], producers: [{ sha: c3, refs: [2] }],
456
+ });
457
+ eq('attribute untouched range: not attributed, no producers', r.claims[1],
458
+ { spec: 'f.txt:9-10', attributed: false, evolvedBy: [], producers: [] });
459
+ const multi = att(dir, 1, ['f.txt:9-9,f.txt:3-3']);
460
+ eq('attribute comma list: union of its ranges', [multi.claims[0].attributed, shas(multi.claims[0])], [true, [c3]]);
461
+ assert.throws(() => att(dir, 1, ['f.txt:nonsense']), /invalid claim/); passed++;
462
+ }
463
+
464
+ // an edit with no trailer is not attributed
465
+ {
466
+ const { dir, f } = baseRepo();
467
+ const t = splice(f, 3, 2, 'T3', 'T4');
468
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
469
+ const c3 = commit(dir, { 'f.txt': splice(t, 3, 1, 'M3') }, 'fix: untracked edit');
470
+ const c = att(dir, 1, ['f.txt:3-3']).claims[0];
471
+ eq('attribute no trailer: not attributed', [c.attributed, c.evolvedBy, c.producers], [false, [], [{ sha: c3, refs: [] }]]);
472
+ }
473
+
474
+ // an unrelated edit elsewhere in the file never attributes the claim
475
+ {
476
+ const { dir, f } = baseRepo();
477
+ const t = splice(f, 3, 2, 'T3', 'T4');
478
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
479
+ commit(dir, { 'f.txt': splice(t, 8, 1, 'M8') }, 'feat: elsewhere', { refs: [2] });
480
+ const c = att(dir, 1, ['f.txt:3-4']).claims[0];
481
+ eq('attribute unrelated edit elsewhere: no producers', [c.attributed, c.producers], [false, []]);
482
+ }
483
+
484
+ // a deletion by #m, claimed as a gap
485
+ {
486
+ const { dir, f } = baseRepo();
487
+ const t = splice(f, 3, 2, 'T3', 'T4');
488
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
489
+ const c3 = commit(dir, { 'f.txt': splice(t, 3, 2) }, 'refactor: remove them', { refs: [2] });
490
+ const c = att(dir, 1, ['f.txt:@2']).claims[0];
491
+ eq('attribute deletion by #2 (@g claim)', [c.attributed, c.evolvedBy, shas(c)], [true, [2], [c3]]);
492
+ }
493
+
494
+ // the ticket's own deletion anchor, later edited around by #m
495
+ {
496
+ const { dir, f } = baseRepo();
497
+ const t = splice(f, 5, 2);
498
+ commit(dir, { 'f.txt': t }, 'feat: delete', { refs: [1] });
499
+ const c3 = commit(dir, { 'f.txt': splice(t, 4, 1, 'X4') }, 'fix: edit at the anchor', { refs: [2] });
500
+ const c = att(dir, 1, ['f.txt:@4']).claims[0];
501
+ eq('attribute own anchor edited by #2', [c.attributed, c.evolvedBy, shas(c)], [true, [2], [c3]]);
502
+ }
503
+
504
+ // a file deleted by #m: the claim naming that path is attributed
505
+ {
506
+ const { dir, f } = baseRepo();
507
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'T3') }, 'feat: ticket', { refs: [1] });
508
+ const c3 = commit(dir, { 'f.txt': null }, 'chore: drop file', { refs: [2] });
509
+ const c = att(dir, 1, ['f.txt:1-10']).claims[0];
510
+ eq('attribute file deleted by #2', [c.attributed, c.evolvedBy, shas(c)], [true, [2], [c3]]);
511
+ }
512
+
513
+ // rename, then an edit: the claim is on the new HEAD path
514
+ {
515
+ const { dir, f } = baseRepo();
516
+ const t = splice(f, 3, 2, 'T3', 'T4');
517
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
518
+ git(dir, ['mv', 'f.txt', 'g.txt']);
519
+ finishCommit(dir, ['commit', '-q'], 'refactor: rename', {});
520
+ const c4 = commit(dir, { 'g.txt': splice(t, 3, 1, 'M3') }, 'feat: edit after rename', { refs: [3] });
521
+ const r = att(dir, 1, ['g.txt:3-3', 'f.txt:3-3']);
522
+ eq('attribute rename then edit: claim on the new path', [r.claims[0].attributed, r.claims[0].evolvedBy, shas(r.claims[0])], [true, [3], [c4]]);
523
+ eq('attribute rename then edit: the old path no longer matches', [r.claims[1].attributed, r.claims[1].producers], [false, []]);
524
+ }
525
+
526
+ // coordinates shift above the claim; matching happens in HEAD coordinates only
527
+ {
528
+ const { dir, f } = baseRepo();
529
+ const t = splice(f, 6, 2, 'T6', 'T7'); // ticket owns 6-7
530
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
531
+ const t3 = splice(t, 2, 0, 'I1', 'I2'); // non-touching insertion above: ticket now 8-9
532
+ commit(dir, { 'f.txt': t3 }, 'feat: insert above', { refs: [2] });
533
+ const t4 = splice(t3, 1, 1); // non-touching deletion above: ticket now 7-8
534
+ commit(dir, { 'f.txt': t4 }, 'refactor: drop first line', { refs: [3] });
535
+ const c5 = commit(dir, { 'f.txt': splice(t4, 7, 1, 'X') }, 'fix: edit the shifted line', { refs: [2] });
536
+ const r = att(dir, 1, ['f.txt:7-7', 'f.txt:6-6', 'f.txt:8-8']);
537
+ eq('attribute shift: HEAD line 7 is the ticket line', [r.claims[0].attributed, r.claims[0].evolvedBy, shas(r.claims[0])], [true, [2], [c5]]);
538
+ eq('attribute shift: HEAD line 6 is not the ticket line', r.claims[1].producers, []);
539
+ eq('attribute shift: untouched ticket line has no producers', r.claims[2].producers, []);
540
+ }
541
+
542
+ // merges: a simple side history is expanded to the exact side commits
543
+ function mergeScenario(sideSetup) {
544
+ const { dir, f } = baseRepo();
545
+ const t = splice(f, 3, 2, 'T3', 'T4');
546
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
547
+ git(dir, ['checkout', '-q', '-b', 'side']);
548
+ const side = sideSetup(dir, t);
549
+ git(dir, ['checkout', '-q', 'main']);
550
+ return { dir, t, f, side };
551
+ }
552
+ {
553
+ let s1; let s2;
554
+ const sc = mergeScenario((dir, t) => {
555
+ s1 = commit(dir, { 'f.txt': splice(t, 3, 1, 'S1_3') }, 'feat: side one', { refs: [2] });
556
+ s2 = commit(dir, { 'f.txt': splice(t, 3, 1, 'S2_3') }, 'feat: side two', { refs: [3] });
557
+ });
558
+ commit(sc.dir, { 'other.txt': 'main moves\n' }, 'chore: main moves');
559
+ const m = mergeBranch(sc.dir, 'side', {}, 'merge: side');
560
+ const c = att(sc.dir, 1, ['f.txt:3-3']).claims[0];
561
+ eq('attribute simple merge: exact producers {s1, s2}', [shas(c), c.attributed, c.evolvedBy], [[s1, s2].sort(), true, [2, 3]]);
562
+ ok('attribute simple merge: the merge itself is not a producer', !shas(c).includes(m));
563
+ }
564
+
565
+ // a conflict-resolved merge is a producer next to the side commit
566
+ {
567
+ let s1;
568
+ const sc = mergeScenario((dir, t) => {
569
+ s1 = commit(dir, { 'f.txt': splice(t, 4, 1, 'S4') }, 'feat: side edit of line 4', { refs: [2] });
570
+ });
571
+ commit(sc.dir, { 'f.txt': splice(sc.t, 3, 1, 'M3') }, 'feat: main edit of line 3', { refs: [4] });
572
+ const resolved = splice(splice(sc.t, 3, 1, 'M3'), 4, 1, 'R4');
573
+ const m = mergeBranch(sc.dir, 'side', { 'f.txt': resolved }, 'merge: resolved', { refs: [5] });
574
+ const c = att(sc.dir, 1, ['f.txt:4-4']).claims[0];
575
+ eq('attribute conflict-resolved merge: {merge, side commit}', [shas(c), c.attributed, c.evolvedBy], [[m, s1].sort(), true, [2, 5]]);
576
+ }
577
+
578
+ // a nested merge falls back to the merge commit itself
579
+ {
580
+ const sc = mergeScenario((dir, t) => {
581
+ commit(dir, { 'f.txt': splice(t, 4, 1, 'S4') }, 'feat: side edit', { refs: [2] });
582
+ git(dir, ['checkout', '-q', '-b', 'sub']);
583
+ commit(dir, { 'u.txt': 'sub\n' }, 'feat: sub work', { refs: [3] });
584
+ git(dir, ['checkout', '-q', 'side']);
585
+ commit(dir, { 'v.txt': 'side\n' }, 'feat: side work', { refs: [4] });
586
+ mergeBranch(dir, 'sub', {}, 'merge: sub into side');
587
+ });
588
+ commit(sc.dir, { 'other.txt': 'main moves\n' }, 'chore: main moves');
589
+ const m = mergeBranch(sc.dir, 'side', {}, 'merge: side');
590
+ const c = att(sc.dir, 1, ['f.txt:4-4']).claims[0];
591
+ eq('attribute nested merge: falls back to the merge, no trailer', [c.attributed, c.producers], [false, [{ sha: m, refs: [] }]]);
592
+ }
593
+
594
+ // a rename on the side falls back to the merge commit itself
595
+ {
596
+ const sc = mergeScenario((dir, t) => {
597
+ commit(dir, { 'f.txt': splice(t, 4, 1, 'S4') }, 'feat: side edit', { refs: [2] });
598
+ git(dir, ['mv', 'f.txt', 'h.txt']);
599
+ finishCommit(dir, ['commit', '-q'], 'refactor: side rename', { refs: [3] });
600
+ });
601
+ commit(sc.dir, { 'other.txt': 'main moves\n' }, 'chore: main moves');
602
+ const m = mergeBranch(sc.dir, 'side', {}, 'merge: side');
603
+ const c = att(sc.dir, 1, ['h.txt:4-4']).claims[0];
604
+ eq('attribute rename on side: falls back to the merge', [c.attributed, c.producers], [false, [{ sha: m, refs: [] }]]);
605
+ }
606
+
607
+ // old-side tracked lines a merge deletes: the merge itself is the producer
608
+ {
609
+ const sc = mergeScenario((dir, t) => {
610
+ commit(dir, { 'f.txt': splice(t, 3, 2) }, 'refactor: side deletes the lines', { refs: [2] });
611
+ });
612
+ commit(sc.dir, { 'other.txt': 'main moves\n' }, 'chore: main moves');
613
+ const m = mergeBranch(sc.dir, 'side', {}, 'merge: side');
614
+ const c = att(sc.dir, 1, ['f.txt:@2']).claims[0];
615
+ eq('attribute merge deleting tracked lines: producer is the merge', [c.attributed, c.producers], [false, [{ sha: m, refs: [] }]]);
616
+ }
617
+
618
+ // ineligible tickets are never attributed
619
+ {
620
+ const { dir, f } = baseRepo();
621
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'M3') }, 'feat: someone', { refs: [2] });
622
+ const r = att(dir, 99, ['f.txt:3-3']);
623
+ eq('attribute no-refs: ineligible', [r.eligible, r.reason], [false, 'no-refs']);
624
+ eq('attribute no-refs: claims not attributed', r.claims, [{ spec: 'f.txt:3-3', attributed: false, evolvedBy: [], producers: [] }]);
625
+ }
626
+ {
627
+ const { dir, f } = baseRepo();
628
+ git(dir, ['checkout', '-q', '-b', 'side']);
629
+ commit(dir, { 'f.txt': splice(f, 2, 1, 'S2') }, 'feat: side part', { refs: [1] });
630
+ git(dir, ['checkout', '-q', 'main']);
631
+ commit(dir, { 'f.txt': splice(f, 8, 1, 'M8') }, 'feat: main part', { refs: [1] });
632
+ mergeBranch(dir, 'side', {}, 'merge: side');
633
+ const r = att(dir, 1, ['f.txt:2-2']);
634
+ eq('attribute non-linear: ineligible, not attributed', [r.eligible, r.reason, r.claims[0].attributed], [false, 'non-linear', false]);
635
+ }
636
+
637
+ // --- end-to-end: the CLI ---------------------------------------------------------
638
+ function cli(args) {
639
+ try {
640
+ const stdout = execFileSync(process.execPath, [scriptPath, ...args], { env: GIT_ENV, stdio: 'pipe', encoding: 'utf8' });
641
+ return { code: 0, json: JSON.parse(stdout) };
642
+ } catch (e) {
643
+ return { code: e.status, json: JSON.parse(String(e.stdout)) };
644
+ }
645
+ }
646
+
647
+ {
648
+ const { dir, f } = baseRepo();
649
+ const t = splice(f, 3, 2, 'T3', 'T4');
650
+ commit(dir, { 'f.txt': t }, 'feat: ticket', { refs: [1] });
651
+ const c3 = commit(dir, { 'f.txt': splice(t, 3, 1, 'M3') }, 'feat: later ticket', { refs: [2] });
652
+ const indexFile = path.join(dir, '..', path.basename(dir) + '-index.json');
653
+ tmpDirs.push(indexFile);
654
+
655
+ const idx = cli(['index', '--repo', dir]);
656
+ fs.writeFileSync(indexFile, JSON.stringify(idx.json));
657
+ eq('cli index: exit 0, head and ticket map', [idx.code, idx.json.head, Object.keys(idx.json.byTicket).sort()], [0, head(dir), ['1', '2']]);
658
+
659
+ const fpRun = cli(['fastpath', '--repo', dir, '--index', indexFile, '1', '2']);
660
+ eq('cli fastpath --index: one entry per ticket', [fpRun.code, fpRun.json.shallow, fpRun.json.tickets.map((x) => [x.ticket, x.class, x.reason])],
661
+ [0, false, [[1, 'changed', 'touching-hunk'], [2, 'unchanged', null]]]);
662
+
663
+ const noIndex = cli(['fastpath', '--repo', dir, '1']);
664
+ eq('cli fastpath without --index scans itself', noIndex.json.tickets[0].reason, 'touching-hunk');
665
+
666
+ const attRun = cli(['attribute', '--repo', dir, '--index', indexFile, '--ticket', '1', '--claim', 'f.txt:3-3', '--claim', 'f.txt:9-10']);
667
+ eq('cli attribute --index: claims in order', [attRun.code, attRun.json.eligible, attRun.json.claims.map((x) => [x.spec, x.attributed, x.evolvedBy])],
668
+ [0, true, [['f.txt:3-3', true, [2]], ['f.txt:9-10', false, []]]]);
669
+ eq('cli attribute: producer carries sha and refs', attRun.json.claims[0].producers, [{ sha: c3, refs: [2] }]);
670
+
671
+ commit(dir, { 'other.txt': 'moves HEAD\n' }, 'chore: moves HEAD');
672
+ const stale = cli(['fastpath', '--repo', dir, '--index', indexFile, '1']);
673
+ eq('cli stale --index: exit 2 with an error', [stale.code, /stale/.test(stale.json.error)], [2, true]);
674
+ const staleAtt = cli(['attribute', '--repo', dir, '--index', indexFile, '--ticket', '1', '--claim', 'f.txt:3-3']);
675
+ eq('cli attribute stale --index: exit 2', staleAtt.code, 2);
676
+
677
+ fs.writeFileSync(indexFile, '{not json');
678
+ eq('cli malformed --index file: exit 2', cli(['fastpath', '--repo', dir, '--index', indexFile, '1']).code, 2);
679
+ }
680
+
681
+ // usage errors exit 2 with a JSON error
682
+ {
683
+ const { dir } = baseRepo();
684
+ eq('cli no subcommand: exit 2', cli([]).code, 2);
685
+ eq('cli unknown subcommand: exit 2', cli(['bogus', '--repo', dir]).code, 2);
686
+ eq('cli fastpath without tickets: exit 2', cli(['fastpath', '--repo', dir]).code, 2);
687
+ eq('cli fastpath non-numeric ticket: exit 2', cli(['fastpath', '--repo', dir, 'abc']).code, 2);
688
+ eq('cli attribute without --ticket: exit 2', cli(['attribute', '--repo', dir, '--claim', 'f.txt:1-1']).code, 2);
689
+ eq('cli attribute bad claim: exit 2', cli(['attribute', '--repo', dir, '--ticket', '1', '--claim', 'nope']).code, 2);
690
+ eq('cli unknown flag: exit 2', cli(['index', '--repo', dir, '--bogus']).code, 2);
691
+ eq('cli --repo without a value: exit 2', cli(['index', '--repo']).code, 2);
692
+ const notRepo = fs.mkdtempSync(path.join(os.tmpdir(), 'trailhead-audit-walk-norepo-'));
693
+ tmpDirs.push(notRepo);
694
+ eq('cli not a repo: exit 2', cli(['index', '--repo', notRepo]).code, 2);
695
+ }
696
+
697
+ // --- review fixes -----------------------------------------------------------------
698
+ // W1: a saved index feeds git argv, so every sha and number in it is validated.
699
+ {
700
+ const { dir, f } = baseRepo();
701
+ commit(dir, { 'f.txt': splice(f, 3, 1, 'T3') }, 'feat: ticket', { refs: [1] });
702
+ const good = buildTrailerIndex(dir);
703
+ const victim = path.join(dir, 'victim.txt');
704
+ fs.writeFileSync(victim, 'keep me\n');
705
+ const sha = good.byTicket['1'][0];
706
+ const bad = {
707
+ 'option-like sha in byTicket': { ...good, byTicket: { 5: ['--output=' + victim] } },
708
+ 'option-like sha in refsBySha': { ...good, refsBySha: { ['--output=' + victim]: [5] } },
709
+ 'non-numeric ticket key': { ...good, byTicket: { 'x': [sha] } },
710
+ 'non-numeric refs value': { ...good, refsBySha: { [sha]: ['--oops'] } },
711
+ 'short sha in byTicket': { ...good, byTicket: { 1: [sha.slice(0, 12)] } },
712
+ 'byTicket value not an array': { ...good, byTicket: { 1: sha } },
713
+ };
714
+ for (const [name, index] of Object.entries(bad)) {
715
+ assert.throws(() => fastPath(dir, 5, index), /malformed/, name); passed++;
716
+ assert.throws(() => attribute(dir, 5, ['f.txt:1-1'], index), /malformed/, name); passed++;
717
+ }
718
+ assert.throws(() => fastPath(dir, 1, { ...good, head: '--output=' + victim }), /malformed/); passed++;
719
+ eq('malformed index never reaches git: victim intact', fs.readFileSync(victim, 'utf8'), 'keep me\n');
720
+ eq('a well formed index still works', fastPath(dir, 1, good).class, 'unchanged');
721
+ }
722
+
723
+ // W2: fastpath and attribute agree when a foreign commit touches a deletion anchor
724
+ {
725
+ const { dir, f } = baseRepo();
726
+ const t = splice(f, 5, 2); // ticket deletes a5-a6: anchor after line 4
727
+ commit(dir, { 'f.txt': t }, 'feat: delete', { refs: [1] });
728
+ const m = splice(t, 4, 1, 'X4');
729
+ commit(dir, { 'f.txt': m }, 'fix: foreign edit at the anchor', { refs: [6] });
730
+ commit(dir, { 'f.txt': splice(m, 8, 1, 'T8') }, 'feat: second', { refs: [1] });
731
+ const fpr = fp(dir, 1);
732
+ const atr = att(dir, 1, ['f.txt:4-4']);
733
+ eq('anchorGuard: fastpath guard, not evolved-eligible', [fpr.class, fpr.reason, fpr.evolvedEligible], ['changed', 'guard', false]);
734
+ eq('anchorGuard: attribute ineligible with the same reason', [atr.eligible, atr.reason], [false, 'guard']);
735
+ eq('anchorGuard: the two agree on eligibility', fpr.evolvedEligible, atr.eligible);
736
+ }
737
+ // a chain guard alone (no anchor involved) stays evolved-eligible in both
738
+ {
739
+ const { dir, f } = baseRepo();
740
+ const t = splice(f, 2, 1, 'T2');
741
+ commit(dir, { 'f.txt': t }, 'feat: first', { refs: [1] });
742
+ const mid = splice(t, 9, 1, 'X9');
743
+ commit(dir, { 'f.txt': mid }, 'fix: foreign edit in the same file', { refs: [6] });
744
+ commit(dir, { 'f.txt': splice(mid, 5, 1, 'T5') }, 'feat: second', { refs: [1] });
745
+ eq('chain guard only: evolved-eligible in both', [fp(dir, 1).evolvedEligible, att(dir, 1, ['f.txt:2-2']).eligible], [true, true]);
746
+ }
747
+
748
+ // I1: a ticket whose commits touch no files is vacuously unchanged
749
+ {
750
+ const { dir } = baseRepo();
751
+ commit(dir, {}, 'chore: empty ticket commit', { refs: [1] });
752
+ commit(dir, { 'other.txt': 'later\n' }, 'chore: unrelated work');
753
+ const r = fp(dir, 1);
754
+ eq('fastPath no files: unchanged, files empty', [r.class, r.reason, r.files], ['unchanged', null, []]);
755
+ const base = baseRepo();
756
+ commit(base.dir, {}, 'chore: first', { refs: [1] });
757
+ commit(base.dir, { 'f.txt': splice(base.f, 3, 1, 'X') }, 'fix: foreign', { refs: [6] });
758
+ commit(base.dir, {}, 'chore: second', { refs: [1] });
759
+ eq('fastPath no files: no whole-tree guard hit', fp(base.dir, 1).class, 'unchanged');
760
+ }
761
+
762
+ // I2: a git exit status above 1 is an error, never "no difference"
763
+ {
764
+ const { dir } = baseRepo();
765
+ assert.throws(() => walk.gitDiffers(dir, ['diff', '--quiet', 'no-such-rev', 'HEAD']), /git diff failed/); passed++;
766
+ eq('gitDiffers: 0 -> false', walk.gitDiffers(dir, ['diff', '--quiet', 'HEAD', 'HEAD']), false);
767
+ commit(dir, { 'other.txt': 'changed\n' }, 'chore: change');
768
+ eq('gitDiffers: 1 -> true', walk.gitDiffers(dir, ['diff', '--quiet', 'HEAD~1', 'HEAD']), true);
769
+ }
770
+
771
+ // I3: the carry-forward branch of the anchor replay, in isolation. The later
772
+ // ticket commit replaces lines 4-5 (which spans the anchor) with ONE line and
773
+ // has no pure deletion of its own, so only a carried anchor sits after line 4.
774
+ {
775
+ const mk = (editLine) => {
776
+ const { dir, f } = baseRepo();
777
+ const t = splice(f, 5, 2); // a1-a4, a7-a10; anchor after line 4
778
+ commit(dir, { 'f.txt': t }, 'feat: delete', { refs: [1] });
779
+ const t2 = splice(t, 4, 2, 'T4'); // a1-a3, T4, a8-a10
780
+ commit(dir, { 'f.txt': t2 }, 'feat: replaces around the anchor', { refs: [1] });
781
+ commit(dir, { 'f.txt': splice(t2, editLine, 1, 'X') }, 'fix: later edit');
782
+ return fp(dir, 1);
783
+ };
784
+ eq('carried anchor: edit of the line after the span is changed', mk(5).reason, 'touching-hunk');
785
+ eq('carried anchor: edit far away is unchanged', mk(7).class, 'unchanged');
786
+ }
787
+
788
+ // I3: a merge hunk that replaces a tracked line: the side commit is the producer
789
+ {
790
+ let s1;
791
+ const sc = mergeScenario((dir, t) => {
792
+ s1 = commit(dir, { 'f.txt': splice(t, 3, 1, 'S3') }, 'feat: side replaces a tracked line', { refs: [2] });
793
+ });
794
+ commit(sc.dir, { 'other.txt': 'main moves\n' }, 'chore: main moves');
795
+ const m = mergeBranch(sc.dir, 'side', {}, 'merge: side');
796
+ const c = att(sc.dir, 1, ['f.txt:3-3']).claims[0];
797
+ eq('attribute merge replacing a tracked line: the side commit, not the merge', [shas(c), c.attributed, c.evolvedBy], [[s1], true, [2]]);
798
+ ok('attribute merge replacing a tracked line: merge is not a producer', !shas(c).includes(m));
799
+ }
800
+
801
+ cleanup();
802
+ console.log(`✓ audit-walk: ${passed} assertions passed`);