@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,685 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ // trailhead audit walk (self-contained, ships in templates/ so it reaches every
4
+ // install channel, like trailhead-commit-msg-sync.js). Runs the deterministic
5
+ // parts of `/trailhead:audit`: the fast path (references/auditing.md step 4)
6
+ // and the attribution walk (step 6). The prose in auditing.md stays the spec;
7
+ // this script implements it. #193.
8
+ //
9
+ // CLI (JSON on stdout, exit 0; usage or fatal errors exit 2 with {"error":...}):
10
+ // index --repo <dir>
11
+ // fastpath --repo <dir> [--index <file>] <n> [<n>...]
12
+ // attribute --repo <dir> [--index <file>] --ticket <n> --claim <spec> [--claim <spec>...]
13
+ // A claim spec is a comma list of `<path>:<s>-<e>` (HEAD line range) or
14
+ // `<path>:@<g>` (deletion gap after HEAD line g). `index` prints the trailer
15
+ // index; save it and pass it back with --index so a whole audit scans once.
16
+ //
17
+ // Git is only ever run through execFileSync('git', ['-C', repo, ...]), never a
18
+ // shell, with LC_ALL=C so output parsing is stable on localized machines.
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const { execFileSync } = require('child_process');
23
+
24
+ // Tokens `#<digits>` delimited by start/end, comma or whitespace, so `#19`
25
+ // never matches inside `#192`. Input is the value of a parsed `Refs:` trailer.
26
+ function parseRefs(value) {
27
+ const found = [];
28
+ const re = /(?:^|[,\s])#(\d+)(?=$|[,\s])/g;
29
+ const text = String(value == null ? '' : value);
30
+ let m;
31
+ while ((m = re.exec(text)) !== null) {
32
+ const n = Number(m[1]);
33
+ if (!found.includes(n)) found.push(n);
34
+ }
35
+ return found;
36
+ }
37
+
38
+ // `@@ -a,k +b,l @@` headers of a unified diff, in order. A missing count is 1.
39
+ // Only lines that START with `@@ ` are headers: diff content lines always begin
40
+ // with ` `, `+` or `-`, so a content line can never be mistaken for one.
41
+ function parseHunks(diffText) {
42
+ const hunks = [];
43
+ const re = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
44
+ for (const line of String(diffText).split('\n')) {
45
+ const m = re.exec(line);
46
+ if (!m) continue;
47
+ hunks.push({
48
+ oldStart: Number(m[1]),
49
+ oldLen: m[2] === undefined ? 1 : Number(m[2]),
50
+ newStart: Number(m[3]),
51
+ newLen: m[4] === undefined ? 1 : Number(m[4]),
52
+ });
53
+ }
54
+ return hunks;
55
+ }
56
+
57
+ // The shared touching rule (auditing.md step 4). `tracked` is a set of old-side
58
+ // line numbers, `anchors` a set of deletion gaps (gap g = after old line g).
59
+ // A hunk touches when its old range overlaps a tracked line or covers line g or
60
+ // g+1 of an anchor; a pure insertion `-b,0` touches when b or b+1 is tracked or
61
+ // an anchor sits at gap b.
62
+ function hunkTouches(hunk, tracked, anchors) {
63
+ const lines = tracked instanceof Set ? tracked : new Set(tracked || []);
64
+ const gaps = anchors instanceof Set ? anchors : new Set(anchors || []);
65
+ if (hunk.oldLen === 0) {
66
+ const b = hunk.oldStart;
67
+ return lines.has(b) || lines.has(b + 1) || gaps.has(b);
68
+ }
69
+ const start = hunk.oldStart;
70
+ const end = hunk.oldStart + hunk.oldLen - 1;
71
+ for (const line of lines) if (line >= start && line <= end) return true;
72
+ for (const g of gaps) if ((g >= start && g <= end) || (g + 1 >= start && g + 1 <= end)) return true;
73
+ return false;
74
+ }
75
+
76
+ // --- git plumbing ----------------------------------------------------------------
77
+ // LC_ALL=C: stable (non-localized) output. Literal pathspecs: a ticket file name
78
+ // is never interpreted as a pathspec pattern.
79
+ const GIT_ENV = { ...process.env, LC_ALL: 'C', GIT_LITERAL_PATHSPECS: '1' };
80
+
81
+ function git(repo, args) {
82
+ return execFileSync('git', ['-C', repo, '-c', 'core.quotePath=false', ...args], {
83
+ encoding: 'utf8', env: GIT_ENV, input: '', stdio: ['pipe', 'pipe', 'pipe'], maxBuffer: 1 << 30,
84
+ });
85
+ }
86
+
87
+ // Exit status of a git call whose status is the answer (0/1); anything that is
88
+ // not a clean exit (git missing, fatal) still throws.
89
+ function gitStatus(repo, args) {
90
+ try {
91
+ git(repo, args);
92
+ return 0;
93
+ } catch (err) {
94
+ if (err && typeof err.status === 'number') return err.status;
95
+ throw err;
96
+ }
97
+ }
98
+
99
+ // `git diff --quiet`-style calls: true when git reports a difference (exit 1),
100
+ // false when it reports none (exit 0), an error for anything else (exit 128).
101
+ function gitDiffers(repo, args) {
102
+ const status = gitStatus(repo, args);
103
+ if (status > 1) throw new Error(`git diff failed (${status})`);
104
+ return status === 1;
105
+ }
106
+
107
+ // Per-call context: the repo plus caches, so repeated ancestry and diff reads
108
+ // during one audit cost one git call each.
109
+ function makeCtx(repo) {
110
+ return { repo, ancestors: new Map(), nameStatus: new Map(), emptyTree: null };
111
+ }
112
+
113
+ // A rev that is not a full object id never reaches git as an argument, where it
114
+ // could be read as an option.
115
+ function assertSha(rev) {
116
+ if (!SHA_RE.test(rev)) throw new Error(`not a full object id: ${rev}`);
117
+ return rev;
118
+ }
119
+
120
+ function isAncestor(ctx, a, b) {
121
+ assertSha(a);
122
+ assertSha(b);
123
+ if (a === b) return true;
124
+ const key = `${a}>${b}`;
125
+ if (!ctx.ancestors.has(key)) {
126
+ const status = gitStatus(ctx.repo, ['merge-base', '--is-ancestor', a, b]);
127
+ if (status > 1) throw new Error(`git merge-base failed (${status})`);
128
+ ctx.ancestors.set(key, status === 0);
129
+ }
130
+ return ctx.ancestors.get(key);
131
+ }
132
+
133
+ function emptyTree(ctx) {
134
+ if (!ctx.emptyTree) ctx.emptyTree = git(ctx.repo, ['hash-object', '-t', 'tree', '--stdin']).trim();
135
+ return ctx.emptyTree;
136
+ }
137
+
138
+ function objectExists(ctx, spec) {
139
+ return gitStatus(ctx.repo, ['cat-file', '-e', spec]) === 0;
140
+ }
141
+
142
+ // `-z` name-status output -> [{ status, oldPath, newPath }]. Renames and copies
143
+ // carry two paths, every other status one (oldPath === newPath).
144
+ function parseNameStatusZ(output) {
145
+ const tokens = String(output).split('\0');
146
+ const entries = [];
147
+ for (let i = 0; i < tokens.length;) {
148
+ const status = tokens[i].replace(/^\n+/, '');
149
+ if (!status) { i++; continue; }
150
+ if (/^[RC]/.test(status)) {
151
+ entries.push({ status: status[0], oldPath: tokens[i + 1], newPath: tokens[i + 2] });
152
+ i += 3;
153
+ } else {
154
+ entries.push({ status: status[0], oldPath: tokens[i + 1], newPath: tokens[i + 1] });
155
+ i += 2;
156
+ }
157
+ }
158
+ return entries;
159
+ }
160
+
161
+ // `git diff -M --name-status p c`, no pathspec, so a rename is seen with both names.
162
+ function readNameStatus(ctx, p, c) {
163
+ const key = `${p}..${c}`;
164
+ if (!ctx.nameStatus.has(key)) {
165
+ ctx.nameStatus.set(key, parseNameStatusZ(git(ctx.repo, ['diff', '-M', '--name-status', '-z', p, c])));
166
+ }
167
+ return ctx.nameStatus.get(key);
168
+ }
169
+
170
+ function readHunks(ctx, p, c, paths) {
171
+ return parseHunks(git(ctx.repo, ['diff', '-M', '-U0', '--no-color', '--no-ext-diff', p, c, '--', ...paths]));
172
+ }
173
+
174
+ // Parents of c, in order ([] for a root commit).
175
+ function parentsOf(ctx, c) {
176
+ return git(ctx.repo, ['rev-list', '--parents', '-n', '1', '--end-of-options', assertSha(c)]).trim().split(' ').slice(1);
177
+ }
178
+
179
+ // The one parent a ticket commit is diffed against: its first parent that
180
+ // descends from (or is) the previous ticket commit, else its first parent; a
181
+ // root commit diffs against the empty tree.
182
+ function chosenParent(ctx, c, from) {
183
+ const parents = parentsOf(ctx, c);
184
+ if (!parents.length) return emptyTree(ctx);
185
+ if (from) {
186
+ for (const p of parents) if (isAncestor(ctx, from, p)) return p;
187
+ }
188
+ return parents[0];
189
+ }
190
+
191
+ // The single chain from `to` back to `from`: at each step the first parent that
192
+ // descends from `from`. Returns [{ p, c }] oldest first, `from` itself excluded.
193
+ function chainBetween(ctx, from, to) {
194
+ const chain = [];
195
+ for (let cur = to; cur !== from;) {
196
+ const p = parentsOf(ctx, cur).find((parent) => isAncestor(ctx, from, parent));
197
+ if (!p) throw new Error(`no chain from ${from} to ${to}`);
198
+ chain.push({ p, c: cur });
199
+ cur = p;
200
+ }
201
+ return chain.reverse();
202
+ }
203
+
204
+ // --- trailer index -----------------------------------------------------------------
205
+ // One `git log` pass over HEAD: every commit's parsed `Refs:` trailer (never
206
+ // prose), indexed by ticket number. Shape: { head, shallow, byTicket, refsBySha }.
207
+ function buildTrailerIndex(repo) {
208
+ const head = git(repo, ['rev-parse', 'HEAD']).trim();
209
+ const shallow = git(repo, ['rev-parse', '--is-shallow-repository']).trim() === 'true';
210
+ const out = git(repo, ['log', '--format=%H%x00%(trailers:key=Refs,valueonly,separator=%x2C)%x1e', 'HEAD']);
211
+ const byTicket = {};
212
+ const refsBySha = {};
213
+ for (const record of out.split('\x1e')) {
214
+ const text = record.replace(/^\n+/, '');
215
+ if (!text) continue;
216
+ const [sha, value] = text.split('\0');
217
+ const refs = parseRefs(value);
218
+ if (!refs.length) continue;
219
+ refsBySha[sha] = refs;
220
+ for (const n of refs) (byTicket[n] = byTicket[n] || []).push(sha);
221
+ }
222
+ return { head, shallow, byTicket, refsBySha };
223
+ }
224
+
225
+ // Full object ids (sha-1 or sha-256), the only form allowed into git's argv.
226
+ const SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
227
+
228
+ // A saved index is only ever used when it is well formed and was taken at the
229
+ // repo's current HEAD; otherwise it is rejected, never silently trusted.
230
+ function validateIndex(repo, index) {
231
+ const shapeOk = index && typeof index === 'object' && typeof index.head === 'string'
232
+ && typeof index.shallow === 'boolean'
233
+ && index.byTicket && typeof index.byTicket === 'object'
234
+ && index.refsBySha && typeof index.refsBySha === 'object';
235
+ if (!shapeOk) throw new Error('malformed index: expected {head, shallow, byTicket, refsBySha}');
236
+ // Everything in the index ends up in git's argv, so it must look exactly like
237
+ // what buildTrailerIndex writes: full hex shas and decimal ticket numbers.
238
+ const numbers = (list) => Array.isArray(list) && list.every((n) => Number.isInteger(n) && n >= 0);
239
+ const wellFormed = SHA_RE.test(index.head)
240
+ && Object.entries(index.byTicket).every(([n, list]) => /^\d+$/.test(n)
241
+ && Array.isArray(list) && list.every((sha) => typeof sha === 'string' && SHA_RE.test(sha)))
242
+ && Object.entries(index.refsBySha).every(([sha, refs]) => SHA_RE.test(sha) && numbers(refs));
243
+ if (!wellFormed) throw new Error('malformed index: shas must be full hex and ticket numbers decimal');
244
+ const current = git(repo, ['rev-parse', 'HEAD']).trim();
245
+ if (index.head !== current) {
246
+ throw new Error(`stale index: taken at ${index.head}, HEAD is ${current}; rebuild it with the index command`);
247
+ }
248
+ return index;
249
+ }
250
+
251
+ function resolveIndex(repo, index) {
252
+ return index === undefined || index === null ? buildTrailerIndex(repo) : validateIndex(repo, index);
253
+ }
254
+
255
+ // --- line mapping -------------------------------------------------------------------
256
+ // A hunk lies wholly before position x (a line, or a gap after line x) when its
257
+ // old range ends before x; a pure insertion `-b,0` when b < x.
258
+ function hunkBefore(hunk, x) {
259
+ return hunk.oldLen > 0 ? hunk.oldStart + hunk.oldLen - 1 < x : hunk.oldStart < x;
260
+ }
261
+
262
+ // Shift of an untouched line or gap: the summed delta of the hunks wholly before it.
263
+ function shiftBy(hunks, x) {
264
+ let delta = 0;
265
+ for (const hunk of hunks) if (hunkBefore(hunk, x)) delta += hunk.newLen - hunk.oldLen;
266
+ return delta;
267
+ }
268
+
269
+ // --- baseline (auditing.md step 4: commits, order, files, tracked lines, anchors) ----
270
+ // Orders the ticket's commits purely by ancestry. Each commit's rank is the
271
+ // number of other ticket commits below it; the set is a total order exactly when
272
+ // the ranks are 0..k-1, otherwise it is non-linear (null).
273
+ function orderByAncestry(ctx, commits) {
274
+ const ranked = commits.map((c) => ({
275
+ c, rank: commits.filter((o) => o !== c && isAncestor(ctx, o, c)).length,
276
+ }));
277
+ ranked.sort((a, b) => a.rank - b.rank);
278
+ return ranked.every((entry, i) => entry.rank === i) ? ranked.map((entry) => entry.c) : null;
279
+ }
280
+
281
+ // Lines `git blame` attributes to a ticket commit at `last`.
282
+ function trackedLinesAt(ctx, last, file, ticketSet) {
283
+ const tracked = new Map();
284
+ for (const line of git(ctx.repo, ['blame', '--porcelain', last, '--', file]).split('\n')) {
285
+ const m = /^([0-9a-f]{40,64}) \d+ (\d+)/.exec(line);
286
+ if (m && ticketSet.has(m[1])) tracked.set(Number(m[2]), []);
287
+ }
288
+ return tracked;
289
+ }
290
+
291
+ // Moves a deletion anchor from ticket commit `from` (gap in that commit's
292
+ // post-image of `startPath`) to `last`, replaying the chain. A later ticket
293
+ // commit's touching hunk is part of the ticket (the anchor is carried to the
294
+ // hunk's new-side end gap); a non-ticket touching hunk gives { guard: true }; a
295
+ // deleted file gives null. Renames are followed.
296
+ function replayAnchor(ctx, from, last, startPath, startGap, ticketSet) {
297
+ let filePath = startPath;
298
+ let gap = startGap;
299
+ for (const { p, c } of chainBetween(ctx, from, last)) {
300
+ const entry = readNameStatus(ctx, p, c).find((e) => e.oldPath === filePath);
301
+ if (!entry) continue;
302
+ if (entry.status === 'D') return null;
303
+ const paths = entry.status === 'R' ? [entry.oldPath, entry.newPath] : [filePath];
304
+ const hunks = readHunks(ctx, p, c, paths);
305
+ const hit = hunks.find((h) => hunkTouches(h, [], [gap]));
306
+ if (hit) {
307
+ if (!ticketSet.has(c)) return { guard: true };
308
+ gap = hit.newLen > 0 ? hit.newStart + hit.newLen - 1 : hit.newStart;
309
+ } else {
310
+ gap += shiftBy(hunks, gap);
311
+ }
312
+ filePath = entry.newPath;
313
+ }
314
+ return { path: filePath, gap };
315
+ }
316
+
317
+ // Everything the fast path and the walk share about one ticket. `result` is
318
+ // either { reason } (no baseline: no-refs, shallow, non-linear, guard) or the
319
+ // baseline itself: { commits, last, files, states, deleted }.
320
+ // states: Map<path at last, { tracked: Map<line, []>, anchors: Map<gap, []> }>
321
+ // deleted: ticket files absent at <last> (ticket-deleted, or renamed away)
322
+ function ticketBaseline(ctx, n, index, { guard }) {
323
+ const commits = index.byTicket[String(n)] || [];
324
+ if (!commits.length) return { reason: 'no-refs', commits: [] };
325
+ if (index.shallow) return { reason: 'shallow', commits };
326
+ const order = orderByAncestry(ctx, commits);
327
+ if (!order) return { reason: 'non-linear', commits };
328
+ const last = order[order.length - 1];
329
+ const ticketSet = new Set(order);
330
+
331
+ const parents = order.map((c, i) => chosenParent(ctx, c, order[i - 1]));
332
+ const fileSet = new Set();
333
+ order.forEach((c, i) => {
334
+ for (const f of git(ctx.repo, ['diff', '--name-only', '-z', parents[i], c]).split('\0')) if (f) fileSet.add(f);
335
+ });
336
+ let files = [...fileSet].sort();
337
+ const known = { commits: order, last, files };
338
+
339
+ // Guard: a non-ticket commit on the chain that touches a ticket file leaves
340
+ // no trustworthy baseline (a side branch surfaces through its merge commit).
341
+ // The anchor check below still runs after a hit, so eligibility is computed
342
+ // the same way whether or not the chain guard fired.
343
+ let chainGuard = false;
344
+ // No files, nothing to guard: an empty pathspec would diff the whole tree.
345
+ if (guard && files.length) {
346
+ for (const { p, c } of chainBetween(ctx, order[0], last)) {
347
+ if (!ticketSet.has(c) && gitDiffers(ctx.repo, ['diff', '--quiet', p, c, '--', ...files])) {
348
+ chainGuard = true;
349
+ break;
350
+ }
351
+ }
352
+ }
353
+
354
+ const states = new Map();
355
+ const deleted = [];
356
+ for (const file of files) {
357
+ if (objectExists(ctx, `${last}:${file}`)) {
358
+ states.set(file, { tracked: trackedLinesAt(ctx, last, file, ticketSet), anchors: new Map() });
359
+ } else {
360
+ deleted.push(file);
361
+ }
362
+ }
363
+
364
+ // Anchors: every pure-deletion hunk of a ticket commit, moved to <last>.
365
+ for (let i = 0; i < order.length; i++) {
366
+ for (const entry of readNameStatus(ctx, parents[i], order[i])) {
367
+ if (entry.status === 'D') continue;
368
+ const paths = entry.oldPath === entry.newPath ? [entry.newPath] : [entry.oldPath, entry.newPath];
369
+ for (const hunk of readHunks(ctx, parents[i], order[i], paths)) {
370
+ if (hunk.newLen !== 0 || hunk.oldLen === 0) continue;
371
+ const moved = replayAnchor(ctx, order[i], last, entry.newPath, hunk.newStart, ticketSet);
372
+ if (moved && moved.guard) return { ...known, reason: 'guard', anchorGuard: true };
373
+ if (!moved || !states.has(moved.path)) {
374
+ if (moved && objectExists(ctx, `${last}:${moved.path}`)) {
375
+ states.set(moved.path, { tracked: new Map(), anchors: new Map() });
376
+ files = [...files, moved.path].sort();
377
+ } else {
378
+ continue;
379
+ }
380
+ }
381
+ states.get(moved.path).anchors.set(moved.gap, []);
382
+ }
383
+ }
384
+ }
385
+ if (chainGuard) return { ...known, files, reason: 'guard', anchorGuard: false };
386
+ return { ...known, files, states, deleted };
387
+ }
388
+
389
+ // --- fast path (auditing.md step 4) --------------------------------------------------
390
+ function dirtyFilesOf(ctx, files) {
391
+ if (!files.length) return [];
392
+ const tokens = git(ctx.repo, ['status', '--porcelain', '-z', '-uall', '--', ...files]).split('\0');
393
+ const dirty = [];
394
+ for (let i = 0; i < tokens.length; i++) {
395
+ const t = tokens[i];
396
+ if (t.length < 4) continue;
397
+ dirty.push(t.slice(3));
398
+ if (/[RC]/.test(t.slice(0, 2))) i++; // a rename entry is followed by its source path
399
+ }
400
+ return dirty.sort();
401
+ }
402
+
403
+ // The decision (step 4 "Decide") of last..HEAD over the ticket's files.
404
+ function decideChange(ctx, base) {
405
+ for (const file of base.deleted) {
406
+ if (objectExists(ctx, `HEAD:${file}`)) return 'recreated';
407
+ }
408
+ const existing = [...base.states.keys()];
409
+ if (existing.length) {
410
+ const gone = parseNameStatusZ(git(ctx.repo, ['diff', '-M', '--name-status', '-z', base.last, 'HEAD', '--', ...existing]));
411
+ if (gone.some((e) => e.status === 'D' || e.status === 'R')) return 'deleted-or-renamed';
412
+ }
413
+ for (const [file, state] of base.states) {
414
+ if (!gitDiffers(ctx.repo, ['diff', '--quiet', base.last, 'HEAD', '--', file])) continue;
415
+ if (git(ctx.repo, ['cat-file', '-s', `${base.last}:${file}`]).trim() === '0') return 'touching-hunk';
416
+ const hunks = parseHunks(git(ctx.repo, ['diff', '-U0', '--no-color', '--no-ext-diff', base.last, 'HEAD', '--', file]));
417
+ if (!hunks.length) return 'non-textual';
418
+ const lines = new Set(state.tracked.keys());
419
+ const gaps = new Set(state.anchors.keys());
420
+ if (hunks.some((h) => hunkTouches(h, lines, gaps))) return 'touching-hunk';
421
+ }
422
+ return null;
423
+ }
424
+
425
+ // Fast path for one ticket: { ticket, class, reason, evolvedEligible, last,
426
+ // commits, files, dirtyFiles }. `index` is optional (built when absent); a saved
427
+ // one that is stale or malformed throws.
428
+ function fastPath(repo, n, index) {
429
+ const idx = resolveIndex(repo, index);
430
+ const ctx = makeCtx(repo);
431
+ const out = {
432
+ ticket: n, class: 'changed', reason: null, evolvedEligible: false,
433
+ last: null, commits: [], files: [], dirtyFiles: [],
434
+ };
435
+ try {
436
+ const base = ticketBaseline(ctx, n, idx, { guard: true });
437
+ if (base.reason && !base.last) return { ...out, reason: base.reason, commits: base.reason === 'shallow' ? base.commits : [] };
438
+ const known = { last: base.last, commits: base.commits, files: base.files };
439
+ // Evolved stays possible after a chain guard (the walk starts at <last>) but
440
+ // not when an anchor could not be replayed.
441
+ if (base.reason) return { ...out, ...known, reason: base.reason, evolvedEligible: !base.anchorGuard };
442
+ const reason = decideChange(ctx, base);
443
+ return {
444
+ ...out, ...known, class: reason ? 'changed' : 'unchanged', reason, evolvedEligible: true,
445
+ dirtyFiles: dirtyFilesOf(ctx, base.files),
446
+ };
447
+ } catch (err) {
448
+ return { ...out, reason: 'git-error' };
449
+ }
450
+ }
451
+
452
+ // --- attribution walk (auditing.md step 6) ---------------------------------------------
453
+ // Claim spec -> [{ path, start, end } | { path, gap }]. A comma list unions its
454
+ // parts (so a path containing a comma cannot be claimed).
455
+ function parseClaim(spec) {
456
+ return String(spec).split(',').map((part) => {
457
+ const m = /^(.+):(?:@(\d+)|(\d+)-(\d+))$/.exec(part);
458
+ if (!m) throw new Error(`invalid claim spec: ${part} (expected <path>:<s>-<e> or <path>:@<g>)`);
459
+ return m[2] !== undefined
460
+ ? { path: m[1], gap: Number(m[2]) }
461
+ : { path: m[1], start: Number(m[3]), end: Number(m[4]) };
462
+ });
463
+ }
464
+
465
+ function union(...lists) {
466
+ return [...new Set(lists.flat())];
467
+ }
468
+
469
+ // Producers of a merge commit's hunk, in c's coordinates: the commits `git log
470
+ // -L` lists over p..c (intermediate overwritten ones included, plus c itself
471
+ // when the merge resolved a conflict on those lines). Trusted only on a simple
472
+ // side history, checked without any pathspec: no nested merge in p..c, no rename
473
+ // of the file on either side. Anything else, or a failure, or an empty result,
474
+ // returns c itself (conservative).
475
+ function mergeProducers(ctx, p, c, entryPaths, hunk) {
476
+ try {
477
+ const range = `${p}..${c}`;
478
+ const merges = git(ctx.repo, ['rev-list', '--merges', range]).split('\n').filter(Boolean);
479
+ if (merges.some((m) => m !== c)) return [c];
480
+ const renamed = parseNameStatusZ(git(ctx.repo, ['log', '-M', '--name-status', '-z', '--format=', range]))
481
+ .some((e) => e.status === 'R' && (entryPaths.includes(e.oldPath) || entryPaths.includes(e.newPath)));
482
+ if (renamed) return [c];
483
+ const newPath = entryPaths[entryPaths.length - 1];
484
+ const out = git(ctx.repo, [
485
+ 'log', '--no-patch', '--format=%H', `-L${hunk.newStart},${hunk.newStart + hunk.newLen - 1}:${newPath}`, range,
486
+ ]);
487
+ const listed = out.split('\n').filter((line) => /^[0-9a-f]{40,64}$/.test(line));
488
+ return listed.length ? listed : [c];
489
+ } catch (err) {
490
+ return [c];
491
+ }
492
+ }
493
+
494
+ // Applies one commit's hunks to a file state. A touching hunk makes its
495
+ // producers part of the lineage: new-side lines become tracked and inherit the
496
+ // lineage of the tracked lines and anchors they replace (or sit next to) plus
497
+ // the producers; a pure deletion leaves an anchor at the new-side gap with the
498
+ // same lineage. Everything else shifts by the summed delta of the hunks before.
499
+ function applyHunks(state, hunks, producersFor) {
500
+ const lines = new Set(state.tracked.keys());
501
+ const gaps = new Set(state.anchors.keys());
502
+ const consumedLines = new Set();
503
+ const consumedGaps = new Set();
504
+ const additions = [];
505
+ for (const hunk of hunks) {
506
+ if (!hunkTouches(hunk, lines, gaps)) continue;
507
+ const inherited = [];
508
+ if (hunk.oldLen === 0) {
509
+ const b = hunk.oldStart;
510
+ for (const l of [b, b + 1]) if (state.tracked.has(l)) inherited.push(state.tracked.get(l));
511
+ if (state.anchors.has(b)) { inherited.push(state.anchors.get(b)); consumedGaps.add(b); }
512
+ } else {
513
+ const end = hunk.oldStart + hunk.oldLen - 1;
514
+ for (const [l, lineage] of state.tracked) {
515
+ if (l >= hunk.oldStart && l <= end) { inherited.push(lineage); consumedLines.add(l); }
516
+ }
517
+ for (const [g, lineage] of state.anchors) {
518
+ if (g >= hunk.oldStart - 1 && g <= end) { inherited.push(lineage); consumedGaps.add(g); }
519
+ }
520
+ }
521
+ const lineage = union(...inherited, producersFor(hunk));
522
+ if (hunk.newLen > 0) {
523
+ for (let i = 0; i < hunk.newLen; i++) additions.push({ line: hunk.newStart + i, lineage });
524
+ } else {
525
+ additions.push({ gap: hunk.newStart, lineage });
526
+ }
527
+ }
528
+ const tracked = new Map();
529
+ const anchors = new Map();
530
+ for (const [l, lineage] of state.tracked) {
531
+ if (!consumedLines.has(l)) tracked.set(l + shiftBy(hunks, l), lineage);
532
+ }
533
+ for (const [g, lineage] of state.anchors) {
534
+ if (!consumedGaps.has(g)) anchors.set(g + shiftBy(hunks, g), lineage);
535
+ }
536
+ for (const add of additions) {
537
+ const target = add.line === undefined ? anchors : tracked;
538
+ const key = add.line === undefined ? add.gap : add.line;
539
+ target.set(key, union(target.get(key) || [], add.lineage));
540
+ }
541
+ return { ...state, tracked, anchors };
542
+ }
543
+
544
+ // Walks every ticket file from <last> to HEAD along the single chain, keeping
545
+ // each file's current path (renames re-key the state). Returns Map<HEAD path, state>.
546
+ function walkToHead(ctx, base, head) {
547
+ let states = new Map([...base.states].map(([file, s]) => [file, { ...s, deleted: false, lineage: [] }]));
548
+ for (const { p, c } of chainBetween(ctx, base.last, head)) {
549
+ const byOld = new Map(readNameStatus(ctx, p, c).map((e) => [e.oldPath, e]));
550
+ const isMerge = parentsOf(ctx, c).length > 1;
551
+ const next = new Map();
552
+ for (const [file, state] of states) {
553
+ const entry = byOld.get(file);
554
+ if (state.deleted || !entry) { next.set(file, state); continue; }
555
+ if (entry.status === 'D') {
556
+ const every = union(...state.tracked.values(), ...state.anchors.values());
557
+ next.set(file, { tracked: new Map(), anchors: new Map(), deleted: true, lineage: union(every, [c]) });
558
+ continue;
559
+ }
560
+ if (entry.status !== 'M' && entry.status !== 'T' && entry.status !== 'R') { next.set(file, state); continue; }
561
+ const entryPaths = entry.status === 'R' ? [entry.oldPath, entry.newPath] : [file];
562
+ const hunks = readHunks(ctx, p, c, entryPaths);
563
+ const producersFor = (hunk) => (isMerge && hunk.newLen > 0 ? mergeProducers(ctx, p, c, entryPaths, hunk) : [c]);
564
+ next.set(entry.newPath, hunks.length ? applyHunks(state, hunks, producersFor) : state);
565
+ }
566
+ states = next;
567
+ }
568
+ return states;
569
+ }
570
+
571
+ // Lineage of the tracked lines and anchors one claim part overlaps in HEAD.
572
+ function lineageOf(finalStates, part) {
573
+ const state = finalStates.get(part.path);
574
+ if (!state) return [];
575
+ if (state.deleted) return state.lineage;
576
+ const found = [];
577
+ if (part.gap !== undefined) {
578
+ if (state.anchors.has(part.gap)) found.push(state.anchors.get(part.gap));
579
+ for (const l of [part.gap, part.gap + 1]) if (state.tracked.has(l)) found.push(state.tracked.get(l));
580
+ } else {
581
+ for (const [l, lineage] of state.tracked) if (l >= part.start && l <= part.end) found.push(lineage);
582
+ for (const [g, lineage] of state.anchors) if (g >= part.start - 1 && g <= part.end) found.push(lineage);
583
+ }
584
+ return union(...found);
585
+ }
586
+
587
+ function emptyClaim(spec) {
588
+ return { spec, attributed: false, evolvedBy: [], producers: [] };
589
+ }
590
+
591
+ // Attribution for one ticket. `claims` are spec strings. Returns { ticket,
592
+ // eligible, reason, claims: [{ spec, attributed, evolvedBy, producers }] }.
593
+ // Attributed when the claim's lineage holds a producer and every producer's
594
+ // parsed `Refs:` names some #m other than n.
595
+ function attribute(repo, n, claims, index) {
596
+ const parsed = (claims || []).map((spec) => ({ spec, parts: parseClaim(spec) }));
597
+ const idx = resolveIndex(repo, index);
598
+ const ctx = makeCtx(repo);
599
+ const ineligible = (reason) => ({ ticket: n, eligible: false, reason, claims: parsed.map((c) => emptyClaim(c.spec)) });
600
+ try {
601
+ const base = ticketBaseline(ctx, n, idx, { guard: false });
602
+ if (base.reason) return ineligible(base.reason);
603
+ const finalStates = walkToHead(ctx, base, idx.head);
604
+ const results = parsed.map(({ spec, parts }) => {
605
+ const shaList = union(...parts.map((part) => lineageOf(finalStates, part))).sort();
606
+ const producers = shaList.map((sha) => ({ sha, refs: idx.refsBySha[sha] || [] }));
607
+ const evolving = (p) => p.refs.filter((m) => m !== n);
608
+ const attributed = producers.length > 0 && producers.every((p) => evolving(p).length > 0);
609
+ const evolvedBy = attributed ? union(...producers.map(evolving)).sort((a, b) => a - b) : [];
610
+ return { spec, attributed, evolvedBy, producers };
611
+ });
612
+ return { ticket: n, eligible: true, reason: null, claims: results };
613
+ } catch (err) {
614
+ return ineligible('git-error');
615
+ }
616
+ }
617
+
618
+ // --- CLI ----------------------------------------------------------------------------------
619
+ class UsageError extends Error {}
620
+
621
+ function parseArgs(argv) {
622
+ const [command, ...rest] = argv;
623
+ if (!['index', 'fastpath', 'attribute'].includes(command)) {
624
+ throw new UsageError('usage: trailhead-audit-walk.js <index|fastpath|attribute> --repo <dir> ...');
625
+ }
626
+ const opts = { command, repo: null, index: null, ticket: null, claims: [], tickets: [] };
627
+ const value = (flag, i) => {
628
+ if (i >= rest.length) throw new UsageError(`usage: ${flag} requires a value`);
629
+ return rest[i];
630
+ };
631
+ const number = (text, what) => {
632
+ if (!/^\d+$/.test(text)) throw new UsageError(`usage: ${what} must be a ticket number, got: ${text}`);
633
+ return Number(text);
634
+ };
635
+ for (let i = 0; i < rest.length; i++) {
636
+ const arg = rest[i];
637
+ if (arg === '--repo') opts.repo = value(arg, ++i);
638
+ else if (arg === '--index') opts.index = value(arg, ++i);
639
+ else if (arg === '--ticket' && command === 'attribute') opts.ticket = number(value(arg, ++i), '--ticket');
640
+ else if (arg === '--claim' && command === 'attribute') opts.claims.push(value(arg, ++i));
641
+ else if (command === 'fastpath' && !arg.startsWith('--')) opts.tickets.push(number(arg, 'ticket'));
642
+ else throw new UsageError(`usage: unknown argument: ${arg}`);
643
+ }
644
+ if (command === 'fastpath' && !opts.tickets.length) throw new UsageError('usage: fastpath needs at least one ticket number');
645
+ if (command === 'attribute' && opts.ticket === null) throw new UsageError('usage: attribute needs --ticket <n>');
646
+ if (command === 'attribute' && !opts.claims.length) throw new UsageError('usage: attribute needs at least one --claim <spec>');
647
+ return opts;
648
+ }
649
+
650
+ function readIndexFile(file) {
651
+ try {
652
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
653
+ } catch (err) {
654
+ throw new Error(`malformed index file ${file}: ${err.message}`);
655
+ }
656
+ }
657
+
658
+ function run(opts, repo) {
659
+ if (opts.command === 'index') return buildTrailerIndex(repo);
660
+ const index = opts.index ? readIndexFile(opts.index) : buildTrailerIndex(repo);
661
+ if (opts.command === 'attribute') return attribute(repo, opts.ticket, opts.claims, index);
662
+ return { shallow: index.shallow === true, tickets: opts.tickets.map((n) => fastPath(repo, n, index)) };
663
+ }
664
+
665
+ // Exit 0 with the result as one JSON document; exit 2 with {"error":...} on a
666
+ // usage error or any fatal failure (not a repo, stale or malformed index, bad claim).
667
+ function main(argv, { cwd, stdout } = {}) {
668
+ const out = stdout || process.stdout;
669
+ try {
670
+ const opts = parseArgs(argv || []);
671
+ const repo = path.resolve(cwd || process.cwd(), opts.repo || '.');
672
+ out.write(JSON.stringify(run(opts, repo)) + '\n');
673
+ return 0;
674
+ } catch (err) {
675
+ out.write(JSON.stringify({ error: err && err.message ? err.message : String(err) }) + '\n');
676
+ return 2;
677
+ }
678
+ }
679
+
680
+ module.exports = { gitDiffers, parseRefs, parseHunks, hunkTouches, buildTrailerIndex, fastPath, attribute, parseArgs, main };
681
+
682
+ if (require.main === module) {
683
+ // exitCode, not process.exit(): a large index on a pipe must flush first.
684
+ process.exitCode = main(process.argv.slice(2), { cwd: process.cwd(), stdout: process.stdout });
685
+ }