devflow-kit 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/README.md +86 -18
  3. package/dist/agents/git.md +824 -0
  4. package/dist/cli/commands/agents.js +6 -1
  5. package/dist/cli/commands/attribution-prompts.js +1 -1
  6. package/dist/cli/commands/compliance-prompts.js +1 -1
  7. package/dist/cli/commands/compliance.js +23 -1
  8. package/dist/cli/commands/init-seed.js +24 -26
  9. package/dist/cli/commands/init.js +502 -71
  10. package/dist/cli/commands/install-report.js +205 -0
  11. package/dist/cli/commands/knowledge/index.js +2 -2
  12. package/dist/cli/commands/knowledge/toggle.js +27 -37
  13. package/dist/cli/commands/learning.js +37 -30
  14. package/dist/cli/commands/memory.js +79 -69
  15. package/dist/cli/commands/prompt-io.js +4 -4
  16. package/dist/cli/commands/security.js +76 -16
  17. package/dist/cli/commands/skills.js +53 -7
  18. package/dist/cli/commands/tracker-prompts.js +145 -0
  19. package/dist/cli/commands/tracker.js +405 -0
  20. package/dist/cli/commands/uninstall.js +211 -65
  21. package/dist/cli.js +2 -0
  22. package/dist/commands/bug-analysis.md +22 -4
  23. package/dist/commands/code-review.md +44 -15
  24. package/dist/commands/debug.md +20 -6
  25. package/dist/commands/dynamic-build.md +289 -67
  26. package/dist/commands/dynamic-plan.md +60 -21
  27. package/dist/commands/dynamic-profile.md +1 -1
  28. package/dist/commands/dynamic-tickets.md +58 -8
  29. package/dist/commands/explore.md +2 -2
  30. package/dist/commands/implement.md +241 -53
  31. package/dist/commands/plan.md +88 -17
  32. package/dist/commands/release.md +64 -17
  33. package/dist/commands/resolve.md +138 -58
  34. package/dist/commands/self-review.md +2 -2
  35. package/dist/core/agent-models.js +55 -12
  36. package/dist/core/assets.js +58 -2
  37. package/dist/core/evidence-policy.js +147 -0
  38. package/dist/core/feature-config.js +130 -64
  39. package/dist/core/feature-switch.js +112 -0
  40. package/dist/core/flags.js +4 -4
  41. package/dist/core/manifest.js +33 -7
  42. package/dist/core/mds-variants.js +861 -0
  43. package/dist/core/model-discovery.js +12 -1
  44. package/dist/core/plugins.js +357 -9
  45. package/dist/core/project-paths.js +1 -1
  46. package/dist/core/proxy-log.js +8 -6
  47. package/dist/core/proxy-state.js +11 -8
  48. package/dist/core/reference-sweep.js +136 -0
  49. package/dist/core/tracker.js +407 -0
  50. package/dist/skills/git/references/decision-markers.md +19 -0
  51. package/dist/skills/git/references/learn-conventions.md +56 -0
  52. package/dist/skills/git/references/pr/check-ci-status.md +14 -0
  53. package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
  54. package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
  55. package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
  56. package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
  57. package/dist/skills/git/references/pr/post-review-summary.md +42 -0
  58. package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
  59. package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
  60. package/dist/skills/git/references/pr/validate-branch.md +18 -0
  61. package/dist/skills/git/references/publication-gate.md +13 -0
  62. package/dist/skills/git/references/tracker/_mcp.md +153 -0
  63. package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
  64. package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
  65. package/dist/skills/git/references/tracker/github/create-release.md +11 -0
  66. package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
  67. package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
  68. package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
  69. package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
  70. package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
  71. package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
  72. package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
  73. package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
  74. package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
  75. package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
  76. package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
  77. package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
  78. package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
  79. package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
  80. package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
  81. package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
  82. package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
  83. package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
  84. package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
  85. package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
  86. package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
  87. package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
  88. package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
  89. package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
  90. package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
  91. package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
  92. package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
  93. package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
  94. package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
  95. package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
  96. package/dist/skills/git/references/trust-rule.md +7 -0
  97. package/dist/targets/claude-code/installer.js +1213 -31
  98. package/dist/targets/claude-code/legacy.js +5 -0
  99. package/dist/targets/claude-code/post-install.js +196 -74
  100. package/dist/targets/claude-code/tracker-install.js +161 -0
  101. package/package.json +4 -3
  102. package/src/assets/agents/code.md +42 -4
  103. package/src/assets/agents/design.md +1 -1
  104. package/src/assets/agents/git.mds +827 -0
  105. package/src/assets/agents/knowledge.md +1 -1
  106. package/src/assets/agents/learning.md +11 -0
  107. package/src/assets/agents/synthesize.md +1 -1
  108. package/src/assets/agents/test.md +16 -5
  109. package/src/assets/agents/tracker.md +467 -0
  110. package/src/assets/agents/validate.md +7 -5
  111. package/src/assets/commands/_partials/_engine.mds +11 -9
  112. package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
  113. package/src/assets/commands/_partials/_knowledge.mds +2 -2
  114. package/src/assets/commands/_partials/_plan_contract.mds +22 -7
  115. package/src/assets/commands/_partials/_preamble.mds +1 -1
  116. package/src/assets/commands/_partials/_publication.mds +3 -1
  117. package/src/assets/commands/_partials/_ticket_template.mds +3 -2
  118. package/src/assets/commands/_partials/_tracker.mds +18 -0
  119. package/src/assets/commands/_partials/_wave.mds +16 -10
  120. package/src/assets/commands/bug-analysis.mds +15 -5
  121. package/src/assets/commands/code-review.mds +34 -14
  122. package/src/assets/commands/debug.mds +11 -4
  123. package/src/assets/commands/dynamic-build.mds +227 -41
  124. package/src/assets/commands/dynamic-plan.mds +35 -13
  125. package/src/assets/commands/dynamic-tickets.mds +47 -5
  126. package/src/assets/commands/implement.mds +206 -52
  127. package/src/assets/commands/plan.mds +70 -17
  128. package/src/assets/commands/release.md +64 -17
  129. package/src/assets/commands/resolve.mds +126 -56
  130. package/src/assets/mds/git/_pr.mds +331 -0
  131. package/src/assets/mds/git/_references.mds +135 -0
  132. package/src/assets/mds/tracker/_common.mds +156 -0
  133. package/src/assets/mds/tracker/_github.mds +472 -0
  134. package/src/assets/mds/tracker/_jira.mds +407 -0
  135. package/src/assets/mds/tracker/_linear.mds +449 -0
  136. package/src/assets/mds/tracker/_mcp.mds +299 -0
  137. package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
  138. package/src/assets/scripts/hooks/background-memory-update +14 -9
  139. package/src/assets/scripts/hooks/capture-prompt +6 -2
  140. package/src/assets/scripts/hooks/capture-question +6 -2
  141. package/src/assets/scripts/hooks/capture-turn +6 -2
  142. package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
  143. package/src/assets/scripts/hooks/ensure-root-gitignore +161 -60
  144. package/src/assets/scripts/hooks/hook-log-init +3 -1
  145. package/src/assets/scripts/hooks/json-helper.cjs +223 -5
  146. package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -1
  147. package/src/assets/scripts/hooks/memory-worker +15 -8
  148. package/src/assets/scripts/hooks/pre-compact-memory +12 -8
  149. package/src/assets/scripts/hooks/preamble +1 -4
  150. package/src/assets/scripts/hooks/queue-append +68 -24
  151. package/src/assets/scripts/hooks/session-start-context +355 -8
  152. package/src/assets/scripts/hooks/session-start-memory +12 -8
  153. package/src/assets/scripts/pr-evidence.cjs +1961 -0
  154. package/src/assets/scripts/redact-secrets.cjs +490 -62
  155. package/src/assets/scripts/release-trace.cjs +1143 -0
  156. package/src/assets/scripts/resolve-evidence-policy.cjs +1065 -0
  157. package/src/assets/scripts/verify-evidence.cjs +1822 -0
  158. package/src/assets/skills/compliance/SKILL.md +2 -0
  159. package/src/assets/skills/docs-framework/SKILL.md +5 -3
  160. package/src/assets/skills/git/SKILL.md +8 -78
  161. package/src/assets/skills/git/references/github-api.md +179 -141
  162. package/src/assets/skills/git/references/patterns.md +11 -6
  163. package/src/assets/skills/review-methodology/SKILL.md +1 -1
  164. package/src/assets/skills/review-methodology/references/patterns.md +6 -61
  165. package/src/assets/skills/review-methodology/references/violations.md +14 -22
  166. package/src/assets/agents/git.md +0 -938
@@ -0,0 +1,1143 @@
1
+ #!/usr/bin/env node
2
+ // src/assets/scripts/release-trace.cjs
3
+ //
4
+ // Release traceability plumbing for `gather-release-evidence` (SDLC-evidence PR5,
5
+ // #364). It makes git calls only, so it runs offline, and it turns a commit range
6
+ // into closed-vocabulary counts: no commit message, author e-mail or path ever
7
+ // reaches stdout or stderr. Installed as a top-level sibling of hud.sh and
8
+ // redact-secrets.cjs under ~/.devflow/scripts/.
9
+ //
10
+ // Usage:
11
+ // node release-trace.cjs last-tag
12
+ // node release-trace.cjs map --from <ref> --grammar github|jira|linear
13
+ // [--key <KEY>] [--traced-file <file>]
14
+ //
15
+ // Both run git in the process's working directory (the repository to trace).
16
+ //
17
+ // stdout (D-TRACE-STDOUT) is empty on every non-zero exit, otherwise:
18
+ // last-tag exactly `LAST_TAG <tag>` or `LAST_TAG none`
19
+ // map line 1: TRACE from:<ref> scanned:<n> traced:<n> untraced:<n>
20
+ // exempt:<n> unmatched:<n> bound:<ok|hit> (one line; wrapped here)
21
+ // then, per listed class in the order untraced, exempt:release,
22
+ // exempt:revert, exempt:bot — newest first, at most 100 lines each:
23
+ // - <sha12> <class> author:<name>
24
+ // and, after a class with more than 100 members, `- …and <n> more`.
25
+ // <name> is the commit's author name when it passes AUTHOR_RE, else
26
+ // `(unprintable)`. Traced commits are counted, never listed. The boundary
27
+ // re-checks every stdout line against these shapes before writing it.
28
+ //
29
+ // Exit codes (a caller treats EVERY non-zero code as "no trace"):
30
+ // 0 ok — the stdout above
31
+ // 1 usage error — stdout entirely empty, usage on stderr
32
+ // 2 input unusable — --from is not a release tag or a commit SHA (or names
33
+ // no commit), --key fails its grammar, or the --traced-file is not a
34
+ // regular file of 40-hex lines
35
+ // 3 write failed — stdout could not be written; discard whatever arrived
36
+ // 4 git failure — git missing, timed out, killed, not a repository, no HEAD,
37
+ // or it answered in a shape this script does not recognise; also any
38
+ // internal error
39
+ // 5 output gate refused — the composed output failed its grammar, or its
40
+ // counts do not sum (traced + untraced + exempt ≠ scanned)
41
+ //
42
+ // Design constraints (binding):
43
+ // - main() returns {code, stdout} and never calls process.exit; the single
44
+ // `require.main === module` boundary is the only stdout write and the only
45
+ // exitCode assignment
46
+ // - every subprocess is git, spawned through git() with an argv array (never a
47
+ // shell), stdin ignored, a timeout and a maxBuffer; every loop is bounded by
48
+ // a constant or by the length of a bounded buffer
49
+ // - a revision reaches git only as a SHA git itself printed, or as a --from
50
+ // that passed its gate (so it never starts with `-`)
51
+ // - stderr carries fixed REASONS only (D-TRACE-STDERR): an input is never echoed
52
+
53
+ 'use strict';
54
+
55
+ const fs = require('fs');
56
+ const path = require('path');
57
+ const childProcess = require('child_process');
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Closed vocabularies and bounds
61
+ // ---------------------------------------------------------------------------
62
+
63
+ /** Exit codes by meaning (see the header). */
64
+ const EXIT_CODES = Object.freeze({
65
+ OK: 0,
66
+ USAGE: 1,
67
+ INPUT_UNUSABLE: 2,
68
+ WRITE_FAILED: 3,
69
+ GIT_FAILURE: 4,
70
+ OUTPUT_GATE_REFUSED: 5,
71
+ });
72
+
73
+ /**
74
+ * D-TRACE-BOUNDS:
75
+ * SCAN_BOUND first-parent commits classified; the walk asks git for one
76
+ * more, and seeing it is `bound:hit`
77
+ * LIST_CAP listed lines per class; the rest is `- …and <n> more`
78
+ * MAX_TRACED_BYTES the --traced-file size (1,598 SHA lines fit)
79
+ * AUTHOR_MAX code points of an author name printed as-is
80
+ */
81
+ const LIMITS = Object.freeze({
82
+ SCAN_BOUND: 500,
83
+ LIST_CAP: 100,
84
+ MAX_TRACED_BYTES: 65536,
85
+ AUTHOR_MAX: 64,
86
+ });
87
+
88
+ const GIT_SHORT_TIMEOUT_MS = 10000;
89
+ const GIT_WALK_TIMEOUT_MS = 30000;
90
+ /** rev-parse prints one SHA. */
91
+ const LINE_MAX_BUFFER = 4096;
92
+ /** Tag names are ≤ 4 KiB each; an overflow is ENOBUFS ⇒ exit 4, never a partial list. */
93
+ const TAGS_MAX_BUFFER = 4 * 1024 * 1024;
94
+ /** 501 SHA lines. */
95
+ const REV_LIST_MAX_BUFFER = 65536;
96
+ /** 500 messages or path lists; an overflow is ENOBUFS ⇒ exit 4 (coverage unknown). */
97
+ const LOG_MAX_BUFFER = 64 * 1024 * 1024;
98
+
99
+ /**
100
+ * D-TRACE-LAST-TAG: a release tag. The same fixed `^v?X.Y.Z$` shape create-release
101
+ * validates (D8), so a prerelease (`v1.1.0-rc.1`) or a marker tag
102
+ * (`sdlc-baseline-2026-09-24`) never matches.
103
+ */
104
+ const RELEASE_TAG_RE = /^v?[0-9]+\.[0-9]+\.[0-9]+$/;
105
+
106
+ /** A --from that is not a tag: an abbreviated or full commit SHA. */
107
+ const HEX_REF_RE = /^[0-9a-f]{7,40}$/;
108
+
109
+ /** A full SHA as git prints it (SHA-1 repositories). */
110
+ const FULL_SHA_RE = /^[0-9a-f]{40}$/;
111
+
112
+ /** A --key, after ASCII-upper normalisation. */
113
+ const KEY_RE = /^[A-Z][A-Z0-9_]{1,9}$/;
114
+
115
+ /**
116
+ * Step 3a's closing-keyword regex, byte-for-byte the literal the built
117
+ * `gather-release-evidence` references state, applied case-insensitively WITHOUT
118
+ * the `u` flag — so a non-ASCII letter (`ſ`, the Kelvin sign) never folds onto an
119
+ * ASCII keyword letter. A parity test pins `.source` and `.flags` to the built text.
120
+ */
121
+ const KEYWORD_RE = /^\(?(close[sd]?|fix(e[sd])?|resolve[sd]?|refs):?$/i;
122
+
123
+ /** Step 3a's trailing-strip class, byte-for-byte the built literal (parity-pinned). */
124
+ const TRAILING_CLASS = '[.,;:)\\]!?]';
125
+
126
+ /**
127
+ * One character of TRAILING_CLASS. The strip walks back from the end one
128
+ * character at a time, so a hostile token (a long run of `.` ending in a letter)
129
+ * costs linear time — a `+$` regex over it backtracks quadratically.
130
+ */
131
+ const TRAILING_CHAR_RE = new RegExp('^' + TRAILING_CLASS + '$');
132
+
133
+ /**
134
+ * The anchored history grammars the gather references state, per provider
135
+ * (parity-pinned to the built text).
136
+ */
137
+ const GRAMMARS = Object.freeze({
138
+ github: /^#[1-9][0-9]{0,8}$/,
139
+ jira: /^[A-Z][A-Z0-9_]{1,9}-[1-9][0-9]{0,8}$/,
140
+ linear: /^[A-Z][A-Z0-9]{0,9}-[1-9][0-9]{0,8}$/,
141
+ });
142
+
143
+ /**
144
+ * D-TRACE-NORMALISE: what each grammar's gate adds beyond the regex, as the
145
+ * gather references state it. `keyed` — the KEY segment must equal --key.
146
+ * `upcase` — ASCII-upper-normalise the candidate first (linear's pre-flight does;
147
+ * jira's does not, so a lowercase jira reference stays untraced). The
148
+ * normalisation maps [a-z] only: String#toUpperCase would turn `ſ` into `S` and
149
+ * `ı` into `I`, admitting a reference nobody wrote in ASCII.
150
+ */
151
+ const GRAMMAR_RULES = Object.freeze({
152
+ github: Object.freeze({ keyed: false, upcase: false }),
153
+ jira: Object.freeze({ keyed: true, upcase: false }),
154
+ linear: Object.freeze({ keyed: true, upcase: true }),
155
+ });
156
+
157
+ /** Every --grammar value. */
158
+ const GRAMMAR_NAMES = Object.freeze(/** @type {Grammar[]} */ (['github', 'jira', 'linear']));
159
+
160
+ /**
161
+ * D-TRACE-EXEMPT: the three exempt classes, each a rule over fields the commit
162
+ * itself asserts — which is why every exempt commit is LISTED, never hidden.
163
+ * release the `/release` commit's strict subject, or a commit whose changed
164
+ * paths are ALL named CHANGELOG.md — and there is at least one: an
165
+ * empty commit is never "CHANGELOG-only" (D-TRACE-EXEMPT-EMPTY)
166
+ * revert a `Revert "…"` subject AND a body naming what it reverts, in git's
167
+ * own words or GitHub's revert-PR body — the body alone, never the
168
+ * subject paragraph (D-TRACE-REVERT-BODY)
169
+ * bot a `[bot]` author name AND a GitHub noreply bot address (D3)
170
+ */
171
+ const EXEMPT = Object.freeze({
172
+ release: Object.freeze({
173
+ subject: /^chore\(release\): v?[0-9]+\.[0-9]+\.[0-9]+$/,
174
+ basename: 'CHANGELOG.md',
175
+ }),
176
+ revert: Object.freeze({
177
+ subject: /^Revert ".+"( \(#[1-9][0-9]*\))?$/,
178
+ body: Object.freeze([
179
+ /This reverts commit [0-9a-f]{40}/,
180
+ /Reverts [A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+#[1-9][0-9]*/,
181
+ ]),
182
+ }),
183
+ bot: Object.freeze({
184
+ nameSuffix: '[bot]',
185
+ email: /^([0-9]+\+)?[A-Za-z0-9-]+\[bot\]@users\.noreply\.github\.com$/,
186
+ }),
187
+ });
188
+
189
+ /** Every class, in classification order (first match wins). */
190
+ const CLASSES = Object.freeze(/** @type {TraceClass[]} */ ([
191
+ 'traced', 'exempt:release', 'exempt:revert', 'exempt:bot', 'untraced',
192
+ ]));
193
+
194
+ /** The classes stdout lists, in print order. */
195
+ const LISTED_CLASSES = Object.freeze(/** @type {TraceClass[]} */ ([
196
+ 'untraced', 'exempt:release', 'exempt:revert', 'exempt:bot',
197
+ ]));
198
+
199
+ /** An author name printed as-is: letters, marks, digits, space and `._[]-`. */
200
+ const AUTHOR_RE = /^[\p{L}\p{M}\p{N} ._\[\]-]{1,64}$/u;
201
+
202
+ const UNPRINTABLE = '(unprintable)';
203
+
204
+ /** D-TRACE-STDOUT: the `map` header — anchored, closed alternations, named groups. */
205
+ const TRACE_HEADER_RE = /^TRACE from:(?<from>v?[0-9]+\.[0-9]+\.[0-9]+|[0-9a-f]{7,40}) scanned:(?<scanned>0|[1-9][0-9]{0,4}) traced:(?<traced>0|[1-9][0-9]{0,4}) untraced:(?<untraced>0|[1-9][0-9]{0,4}) exempt:(?<exempt>0|[1-9][0-9]{0,4}) unmatched:(?<unmatched>0|[1-9][0-9]{0,4}) bound:(?<bound>ok|hit)$/;
206
+
207
+ /** D-TRACE-STDOUT: one listed commit. */
208
+ const TRACE_ENTRY_RE = /^- (?<sha>[0-9a-f]{12}) (?<cls>untraced|exempt:release|exempt:revert|exempt:bot) author:(?<author>[\p{L}\p{M}\p{N} ._\[\]-]{1,64}|\(unprintable\))$/u;
209
+
210
+ /** D-TRACE-STDOUT: the overflow line closing a class past LIST_CAP. */
211
+ const TRACE_MORE_RE = /^- …and (?<n>[1-9][0-9]{0,4}) more$/;
212
+
213
+ /** D-TRACE-STDOUT: the `last-tag` line. */
214
+ const LAST_TAG_LINE_RE = /^LAST_TAG (?:none|v?[0-9]+\.[0-9]+\.[0-9]+)$/;
215
+
216
+ /**
217
+ * D-TRACE-STDERR: every diagnostic this script prints. Fixed strings — a ref, a
218
+ * key, a path or a git answer is never interpolated, so nothing hostile reaches
219
+ * the calling agent's context through stderr either.
220
+ */
221
+ const REASONS = Object.freeze({
222
+ BAD_REF: '--from is not a release tag (v?X.Y.Z) or a 7-40 character lowercase commit SHA',
223
+ UNKNOWN_REF: '--from names no commit in this repository',
224
+ BAD_KEY: '--key does not match ^[A-Z][A-Z0-9_]{1,9}$ after ASCII-upper normalisation',
225
+ BAD_TRACED_FILE: '--traced-file is not a regular file of lowercase 40-hex lines',
226
+ NO_HEAD: 'git could not resolve HEAD (not a repository, no commit yet, or git unavailable)',
227
+ GIT_FAILED: 'a git call failed, timed out, or answered in an unrecognised shape',
228
+ GATE_REFUSED: 'the output gate refused the composed output',
229
+ INTERNAL: 'internal error',
230
+ });
231
+
232
+ const USAGE = [
233
+ 'Usage: node release-trace.cjs last-tag',
234
+ ' node release-trace.cjs map --from <ref> --grammar github|jira|linear [--key <KEY>] [--traced-file <file>]',
235
+ ].join('\n');
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Types
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * @typedef {'github' | 'jira' | 'linear'} Grammar
243
+ * @typedef {'traced' | 'exempt:release' | 'exempt:revert' | 'exempt:bot' | 'untraced'} TraceClass
244
+ *
245
+ * @typedef {{ sha: string, name: string, email: string, subject: string, message: string, paths: readonly string[] }} Commit
246
+ * One first-parent commit: author name/e-mail, subject (`%s`, which git folds
247
+ * onto one line — read by the exempt rules only), the whole message (`%B`, the
248
+ * lines step 3a reads — D-TRACE-FULL-MESSAGE), and the paths it changes against
249
+ * its first parent.
250
+ *
251
+ * @typedef {{ grammar: Grammar, key: string | null, traced: ReadonlySet<string> }} ClassifyContext
252
+ *
253
+ * @typedef {{ sha: string, cls: TraceClass, author: string }} TraceRecord
254
+ *
255
+ * @typedef {{ from: string, bound: 'ok' | 'hit', unmatched: number, records: readonly TraceRecord[] }} TraceSummary
256
+ * `records` newest first; `records.length` is `scanned`.
257
+ *
258
+ * @typedef {{ from: string, scanned: number, bound: 'ok' | 'hit', unmatched: number }} ExpectedHeader
259
+ *
260
+ * @typedef {{ status: number | null, stdout?: Buffer | string, stderr?: Buffer | string, error?: { code?: string } }} ExecResult
261
+ * @typedef {(file: string, args: string[], opts: object) => ExecResult} ExecFn
262
+ *
263
+ * @typedef {{ exec: ExecFn, env: NodeJS.ProcessEnv, cwd: string, stderr: (text: string) => void }} Io
264
+ *
265
+ * @typedef {{ kind: 'usage' }
266
+ * | { kind: 'last-tag' }
267
+ * | { kind: 'map', from: string, grammar: Grammar, key: string | null, tracedFile: string | null }} ParsedArgs
268
+ *
269
+ * @typedef {{ exec?: ExecFn, cwd?: string, stderr?: (text: string) => void, render?: (s: TraceSummary) => unknown }} MainDeps
270
+ * `render` is injected by tests only, to reach the output gate.
271
+ *
272
+ * @typedef {{ code: number, stdout: string }} Outcome
273
+ */
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Pure core — tags
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * A numeric string without its leading zeros (`''` ⇒ `'0'`), compared exactly at
281
+ * any length — Number() loses precision past 2^53.
282
+ *
283
+ * @param {string} digits
284
+ * @returns {string}
285
+ */
286
+ function stripZeros(digits) {
287
+ const s = digits.replace(/^0+/, '');
288
+ return s === '' ? '0' : s;
289
+ }
290
+
291
+ /**
292
+ * @param {string} a digits without leading zeros
293
+ * @param {string} b
294
+ * @returns {number}
295
+ */
296
+ function compareDigits(a, b) {
297
+ if (a.length !== b.length) return a.length - b.length;
298
+ return a < b ? -1 : a > b ? 1 : 0;
299
+ }
300
+
301
+ /**
302
+ * D-TRACE-LAST-TAG: the highest release tag among `names`, or null. Versions
303
+ * compare numerically, component by component. git's `--sort=-v:refname` is not
304
+ * used: it ranks `v1.0.0` above `1.2.0` (a letter sorts after a digit), which
305
+ * would pick the wrong tag in a repository that mixes the two spellings. Equal
306
+ * versions (`v1.2.0` and `1.2.0`, `1.02.0`) tie-break on the larger string, so
307
+ * the answer never depends on input order.
308
+ *
309
+ * @param {readonly string[]} names
310
+ * @returns {string | null}
311
+ */
312
+ function selectLastTag(names) {
313
+ /** @type {string | null} */
314
+ let best = null;
315
+ /** @type {string[]} */
316
+ let bestParts = [];
317
+ for (const name of names) {
318
+ if (typeof name !== 'string' || !RELEASE_TAG_RE.test(name)) continue;
319
+ const parts = name.replace(/^v/, '').split('.').map(stripZeros);
320
+ let cmp = 0;
321
+ if (best === null) {
322
+ cmp = 1;
323
+ } else {
324
+ for (let i = 0; i < 3 && cmp === 0; i++) cmp = compareDigits(parts[i], bestParts[i]);
325
+ if (cmp === 0) cmp = name > best ? 1 : -1;
326
+ }
327
+ if (cmp > 0) {
328
+ best = name;
329
+ bestParts = parts;
330
+ }
331
+ }
332
+ return best;
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Pure core — classification
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /**
340
+ * D-TRACE-NORMALISE: [a-z] ⇒ [A-Z], nothing else.
341
+ *
342
+ * @param {string} s
343
+ * @returns {string}
344
+ */
345
+ function asciiUpper(s) {
346
+ return s.replace(/[a-z]/g, c => String.fromCharCode(c.charCodeAt(0) - 32));
347
+ }
348
+
349
+ /**
350
+ * Strip one leading `(` and every trailing TRAILING_CLASS character, in linear time.
351
+ *
352
+ * @param {string} part
353
+ * @returns {string}
354
+ */
355
+ function stripCandidate(part) {
356
+ const start = part.startsWith('(') ? 1 : 0;
357
+ let end = part.length;
358
+ while (end > start && TRAILING_CHAR_RE.test(part[end - 1])) end--;
359
+ return part.slice(start, end);
360
+ }
361
+
362
+ /**
363
+ * Whether one step-3a candidate survives the grammar's gate.
364
+ *
365
+ * @param {string} candidate
366
+ * @param {Grammar} grammar
367
+ * @param {string | null} key
368
+ * @returns {string | null} the reference as gated, or null
369
+ */
370
+ function gateCandidate(candidate, grammar, key) {
371
+ const rules = GRAMMAR_RULES[grammar];
372
+ const value = rules.upcase ? asciiUpper(candidate) : candidate;
373
+ if (!GRAMMARS[grammar].test(value)) return null;
374
+ if (rules.keyed && value.slice(0, value.indexOf('-')) !== key) return null;
375
+ return value;
376
+ }
377
+
378
+ /**
379
+ * Step 3a, executed, then the grammar's gate: the first reference `message`
380
+ * yields, or null. Per line, a whitespace token matching KEYWORD_RE opens a run:
381
+ * the next token, plus each further token while the previous one ends in `,`.
382
+ * Each run token splits on `,`, is stripped, and non-empty parts are gated.
383
+ *
384
+ * Linear in the message: a keyword token never ends in `,` (KEYWORD_RE is
385
+ * anchored), so every comma run is walked by at most the one keyword before it.
386
+ *
387
+ * @param {string} message
388
+ * @param {Grammar} grammar
389
+ * @param {string | null} key
390
+ * @returns {string | null}
391
+ */
392
+ function findReference(message, grammar, key) {
393
+ for (const line of message.split('\n')) {
394
+ const tokens = line.split(/\s+/).filter(t => t !== '');
395
+ for (let i = 0; i < tokens.length; i++) {
396
+ if (!KEYWORD_RE.test(tokens[i])) continue;
397
+ for (let j = i + 1; j < tokens.length; j++) {
398
+ for (const part of tokens[j].split(',')) {
399
+ const stripped = stripCandidate(part);
400
+ if (stripped === '') continue;
401
+ const ref = gateCandidate(stripped, grammar, key);
402
+ if (ref !== null) return ref;
403
+ }
404
+ if (!tokens[j].endsWith(',')) break;
405
+ }
406
+ }
407
+ }
408
+ return null;
409
+ }
410
+
411
+ /** A line git treats as blank when it splits a message: empty or ASCII whitespace only. */
412
+ const BLANK_LINE_RE = /^[ \t\v\f\r]*$/;
413
+
414
+ /**
415
+ * D-TRACE-REVERT-BODY: the body of a `%B` message, split as git splits it —
416
+ * leading blank lines skipped, the subject paragraph ended by the first blank
417
+ * line, the body everything after that line — or '' when there is none.
418
+ *
419
+ * The revert rule keys on the line git GENERATES in a revert's body. Since the
420
+ * scan reads the whole message (D-TRACE-FULL-MESSAGE), testing the message would
421
+ * let a body-less commit whose subject quotes that line pass as a revert.
422
+ *
423
+ * @param {string} message
424
+ * @returns {string}
425
+ */
426
+ function messageBody(message) {
427
+ const lines = message.split('\n');
428
+ let i = 0;
429
+ while (i < lines.length && BLANK_LINE_RE.test(lines[i])) i++;
430
+ while (i < lines.length && !BLANK_LINE_RE.test(lines[i])) i++;
431
+ return lines.slice(i + 1).join('\n');
432
+ }
433
+
434
+ /**
435
+ * D-TRACE-EXEMPT-EMPTY: at least one path, and every one named CHANGELOG.md.
436
+ *
437
+ * @param {readonly string[]} paths
438
+ * @returns {boolean}
439
+ */
440
+ function isChangelogOnly(paths) {
441
+ return paths.length > 0
442
+ && paths.every(p => p.slice(p.lastIndexOf('/') + 1) === EXEMPT.release.basename);
443
+ }
444
+
445
+ /**
446
+ * D-TRACE-CLASSIFY: one first-parent commit's class. First match wins, in
447
+ * CLASSES order, and the terminal arm is `untraced` — an input no rule
448
+ * recognises surfaces in the confirm rather than passing silently (avoids PF-075).
449
+ *
450
+ * @param {Commit} commit
451
+ * @param {ClassifyContext} ctx
452
+ * @returns {TraceClass}
453
+ */
454
+ function classify(commit, ctx) {
455
+ if (ctx.traced.has(commit.sha)) return 'traced';
456
+ if (findReference(commit.message, ctx.grammar, ctx.key) !== null) return 'traced';
457
+ if (EXEMPT.release.subject.test(commit.subject) || isChangelogOnly(commit.paths)) return 'exempt:release';
458
+ if (EXEMPT.revert.subject.test(commit.subject)) {
459
+ const body = messageBody(commit.message);
460
+ if (EXEMPT.revert.body.some(re => re.test(body))) return 'exempt:revert';
461
+ }
462
+ if (commit.name.endsWith(EXEMPT.bot.nameSuffix) && EXEMPT.bot.email.test(commit.email)) return 'exempt:bot';
463
+ return 'untraced';
464
+ }
465
+
466
+ // ---------------------------------------------------------------------------
467
+ // Pure core — rendering and the output gate
468
+ // ---------------------------------------------------------------------------
469
+
470
+ /**
471
+ * An author name as stdout may carry it.
472
+ *
473
+ * @param {string} name
474
+ * @returns {string}
475
+ */
476
+ function authorLabel(name) {
477
+ return typeof name === 'string' && AUTHOR_RE.test(name) ? name : UNPRINTABLE;
478
+ }
479
+
480
+ /**
481
+ * D-TRACE-STDOUT: compose the `map` output. Counts come from `records` alone, so
482
+ * they sum by construction; the gate below re-checks that independently.
483
+ *
484
+ * @param {TraceSummary} summary
485
+ * @returns {string}
486
+ */
487
+ function render(summary) {
488
+ /** @type {Record<string, number>} */
489
+ const counts = { traced: 0, 'exempt:release': 0, 'exempt:revert': 0, 'exempt:bot': 0, untraced: 0 };
490
+ for (const r of summary.records) counts[r.cls]++;
491
+ const exempt = counts['exempt:release'] + counts['exempt:revert'] + counts['exempt:bot'];
492
+ const lines = ['TRACE from:' + summary.from
493
+ + ' scanned:' + summary.records.length
494
+ + ' traced:' + counts.traced
495
+ + ' untraced:' + counts.untraced
496
+ + ' exempt:' + exempt
497
+ + ' unmatched:' + summary.unmatched
498
+ + ' bound:' + summary.bound];
499
+ for (const cls of LISTED_CLASSES) {
500
+ const members = summary.records.filter(r => r.cls === cls);
501
+ for (const r of members.slice(0, LIMITS.LIST_CAP)) {
502
+ lines.push('- ' + r.sha.slice(0, 12) + ' ' + cls + ' author:' + authorLabel(r.author));
503
+ }
504
+ if (members.length > LIMITS.LIST_CAP) lines.push('- …and ' + (members.length - LIMITS.LIST_CAP) + ' more');
505
+ }
506
+ return lines.join('\n') + '\n';
507
+ }
508
+
509
+ /**
510
+ * D-TRACE-GATE: whether `text` is a well-formed, internally coherent `map`
511
+ * output — and, when `expected` is given, one describing THIS run.
512
+ * - the header matches TRACE_HEADER_RE, traced + untraced + exempt = scanned,
513
+ * scanned ≤ SCAN_BOUND, and `bound:hit` only at scanned = SCAN_BOUND
514
+ * - every further line is an entry or an overflow line, grouped by class in
515
+ * LISTED_CLASSES order, ≤ LIST_CAP entries per class, an overflow line only
516
+ * after a full class
517
+ * - listed + overflow = the header's untraced count, and the three exempt
518
+ * classes together = its exempt count
519
+ *
520
+ * @param {unknown} text
521
+ * @param {ExpectedHeader} [expected]
522
+ * @returns {boolean}
523
+ */
524
+ function checkTraceOutput(text, expected) {
525
+ if (typeof text !== 'string' || !text.endsWith('\n')) return false;
526
+ const lines = text.slice(0, -1).split('\n');
527
+ const header = TRACE_HEADER_RE.exec(lines[0]);
528
+ if (header === null || header.groups === undefined) return false;
529
+ const g = header.groups;
530
+ const n = {
531
+ scanned: Number(g.scanned), traced: Number(g.traced), untraced: Number(g.untraced),
532
+ exempt: Number(g.exempt), unmatched: Number(g.unmatched),
533
+ };
534
+ if (n.traced + n.untraced + n.exempt !== n.scanned) return false;
535
+ if (n.scanned > LIMITS.SCAN_BOUND) return false;
536
+ if (g.bound === 'hit' && n.scanned !== LIMITS.SCAN_BOUND) return false;
537
+ if (expected !== undefined && (g.from !== expected.from || n.scanned !== expected.scanned
538
+ || g.bound !== expected.bound || n.unmatched !== expected.unmatched)) {
539
+ return false;
540
+ }
541
+
542
+ /** @type {Record<string, number>} */
543
+ const totals = {};
544
+ let i = 1;
545
+ for (const cls of LISTED_CLASSES) {
546
+ let listed = 0;
547
+ let more = 0;
548
+ for (; i < lines.length; i++) {
549
+ const entry = TRACE_ENTRY_RE.exec(lines[i]);
550
+ if (entry === null || entry.groups === undefined || entry.groups.cls !== cls) break;
551
+ listed++;
552
+ }
553
+ const overflow = i < lines.length ? TRACE_MORE_RE.exec(lines[i]) : null;
554
+ if (overflow !== null && overflow.groups !== undefined) {
555
+ if (listed !== LIMITS.LIST_CAP) return false;
556
+ more = Number(overflow.groups.n);
557
+ i++;
558
+ }
559
+ if (listed > LIMITS.LIST_CAP) return false;
560
+ totals[cls] = listed + more;
561
+ }
562
+ if (i !== lines.length) return false;
563
+ if (totals.untraced !== n.untraced) return false;
564
+ return totals['exempt:release'] + totals['exempt:revert'] + totals['exempt:bot'] === n.exempt;
565
+ }
566
+
567
+ // ---------------------------------------------------------------------------
568
+ // Pure core — parsing git's answers
569
+ // ---------------------------------------------------------------------------
570
+
571
+ /**
572
+ * `git for-each-ref --format=%(refname)%00` ⇒ tag names, or null when a record is
573
+ * not a refs/tags/ ref. Records end `\0\n`; a ref name holds neither byte.
574
+ *
575
+ * @param {string} text
576
+ * @returns {string[] | null}
577
+ */
578
+ function parseTagRefs(text) {
579
+ if (text === '') return [];
580
+ if (!text.endsWith('\0\n')) return null;
581
+ /** @type {string[]} */
582
+ const names = [];
583
+ for (const record of text.slice(0, -2).split('\0\n')) {
584
+ if (!record.startsWith('refs/tags/') || record.length === 'refs/tags/'.length) return null;
585
+ names.push(record.slice('refs/tags/'.length));
586
+ }
587
+ return names;
588
+ }
589
+
590
+ /**
591
+ * `git rev-list` ⇒ full SHAs, or null when any line is not one.
592
+ *
593
+ * @param {string} text
594
+ * @returns {string[] | null}
595
+ */
596
+ function parseShaLines(text) {
597
+ if (text === '') return [];
598
+ if (!text.endsWith('\n')) return null;
599
+ const shas = text.slice(0, -1).split('\n');
600
+ return shas.every(s => FULL_SHA_RE.test(s)) ? shas : null;
601
+ }
602
+
603
+ /**
604
+ * D-TRACE-PARSE (messages): split `--format=%H%x00%an%x00%ae%x00%s%x00%B%x1e`
605
+ * output for exactly the commits `shas` names, in order, or null.
606
+ *
607
+ * Positional and exact: git stops printing a field at a NUL (a crafted object
608
+ * cannot smuggle one into a message or an ident), so the output's only NULs are
609
+ * the four per record the format writes, and the total is checked. The message
610
+ * token of record k ends with `\x1e\n` plus record k+1's SHA — a fixed-length
611
+ * suffix checked against the rev-list answer — so a message holding `\x1e\n` and
612
+ * a SHA cannot move a record boundary.
613
+ *
614
+ * @param {string} text
615
+ * @param {readonly string[]} shas
616
+ * @returns {Array<Omit<Commit, 'paths'>> | null}
617
+ */
618
+ function parseMessageLog(text, shas) {
619
+ const tokens = text.split('\0');
620
+ if (shas.length === 0 || tokens.length !== 4 * shas.length + 1) return null;
621
+ if (tokens[0] !== shas[0]) return null;
622
+ const out = [];
623
+ for (let k = 0; k < shas.length; k++) {
624
+ const tail = tokens[4 * k + 4];
625
+ const last = k === shas.length - 1;
626
+ const suffix = last ? '\x1e\n' : '\x1e\n' + shas[k + 1];
627
+ if (!tail.endsWith(suffix)) return null;
628
+ out.push({
629
+ sha: shas[k],
630
+ name: tokens[4 * k + 1],
631
+ email: tokens[4 * k + 2],
632
+ subject: tokens[4 * k + 3],
633
+ message: tail.slice(0, tail.length - suffix.length),
634
+ });
635
+ }
636
+ return out;
637
+ }
638
+
639
+ /**
640
+ * D-TRACE-PARSE (paths): split `-z --name-only --format=%x00%H` output for exactly
641
+ * the commits `shas` names, in order, or null.
642
+ *
643
+ * Layout per commit: `\0<sha>\0`, then — when it changes anything — `\n` and each
644
+ * path NUL-terminated. A path is never empty and never holds a NUL, so an EMPTY
645
+ * token can only be the boundary the format writes: whatever bytes a hostile file
646
+ * name carries (`\x1e`, a newline, a whole SHA), it cannot open a record.
647
+ *
648
+ * @param {string} text
649
+ * @param {readonly string[]} shas
650
+ * @returns {string[][] | null}
651
+ */
652
+ function parsePathLog(text, shas) {
653
+ const tokens = text.split('\0');
654
+ if (shas.length === 0 || tokens[0] !== '' || tokens[tokens.length - 1] !== '') return null;
655
+ /** @type {string[][]} */
656
+ const out = [];
657
+ let i = 1;
658
+ for (let k = 0; k < shas.length; k++) {
659
+ if (i > 1) {
660
+ if (tokens[i] !== '') return null;
661
+ i++;
662
+ }
663
+ if (tokens[i] !== shas[k]) return null;
664
+ i++;
665
+ /** @type {string[]} */
666
+ const paths = [];
667
+ for (; i < tokens.length - 1 && tokens[i] !== ''; i++) {
668
+ const p = paths.length === 0 ? tokens[i].slice(1) : tokens[i];
669
+ if ((paths.length === 0 && !tokens[i].startsWith('\n')) || p === '') return null;
670
+ paths.push(p);
671
+ }
672
+ out.push(paths);
673
+ }
674
+ return i === tokens.length - 1 ? out : null;
675
+ }
676
+
677
+ /**
678
+ * The --traced-file's SHAs, or null: lowercase 40-hex lines, `\n`-separated, one
679
+ * optional final newline, nothing else (no blank line, no CR). Empty is none.
680
+ *
681
+ * @param {Buffer} bytes
682
+ * @returns {Set<string> | null}
683
+ */
684
+ function parseTracedFile(bytes) {
685
+ const text = bytes.toString('latin1');
686
+ if (text === '') return new Set();
687
+ const lines = (text.endsWith('\n') ? text.slice(0, -1) : text).split('\n');
688
+ return lines.every(l => FULL_SHA_RE.test(l)) ? new Set(lines) : null;
689
+ }
690
+
691
+ // ---------------------------------------------------------------------------
692
+ // Argument parsing
693
+ // ---------------------------------------------------------------------------
694
+
695
+ /** The map flags, each taking one value. */
696
+ const MAP_FLAGS = Object.freeze(['--from', '--grammar', '--key', '--traced-file']);
697
+
698
+ /**
699
+ * Parse argv. Anything outside the synopsis is a usage error: an unknown or
700
+ * repeated flag, `--flag=value`, a flag with no value, a missing --from or
701
+ * --grammar, a grammar outside GRAMMAR_NAMES, --key missing for a keyed grammar
702
+ * or given for github. The VALUES of --from and --key are gated in main() (exit 2).
703
+ *
704
+ * @param {readonly string[]} argv process.argv
705
+ * @returns {ParsedArgs}
706
+ */
707
+ function parseArgs(argv) {
708
+ const rest = Array.isArray(argv) ? argv.slice(2) : [];
709
+ if (rest.length === 1 && rest[0] === 'last-tag') return { kind: 'last-tag' };
710
+ if (rest[0] !== 'map') return { kind: 'usage' };
711
+
712
+ /** @type {Map<string, string>} */
713
+ const flags = new Map();
714
+ for (let i = 1; i < rest.length; i += 2) {
715
+ const name = rest[i];
716
+ const value = rest[i + 1];
717
+ if (!MAP_FLAGS.includes(name) || typeof value !== 'string' || flags.has(name)) return { kind: 'usage' };
718
+ flags.set(name, value);
719
+ }
720
+ const from = flags.get('--from');
721
+ const grammar = /** @type {Grammar | undefined} */ (flags.get('--grammar'));
722
+ if (from === undefined || grammar === undefined || !GRAMMAR_NAMES.includes(grammar)) return { kind: 'usage' };
723
+ const key = flags.get('--key');
724
+ if (GRAMMAR_RULES[grammar].keyed !== (key !== undefined)) return { kind: 'usage' };
725
+ const tracedFile = flags.get('--traced-file');
726
+ return {
727
+ kind: 'map',
728
+ from,
729
+ grammar,
730
+ key: key === undefined ? null : key,
731
+ tracedFile: tracedFile === undefined ? null : tracedFile,
732
+ };
733
+ }
734
+
735
+ // ---------------------------------------------------------------------------
736
+ // Imperative shell — git, files
737
+ // ---------------------------------------------------------------------------
738
+
739
+ /** @param {unknown} value @returns {Buffer} */
740
+ function asBuffer(value) {
741
+ if (Buffer.isBuffer(value)) return value;
742
+ if (typeof value === 'string') return Buffer.from(value, 'utf8');
743
+ return Buffer.alloc(0);
744
+ }
745
+
746
+ /**
747
+ * @typedef {{ ok: boolean, answered: boolean, stdout: string }} GitResult
748
+ * `ok` — exit 0 with no spawn error. `answered` — git ran to completion and
749
+ * exited on its own (an answered non-zero is a real "no": no such ref); a
750
+ * timeout, ENOBUFS, a kill or a missing git is NOT an answer.
751
+ */
752
+
753
+ /**
754
+ * THE subprocess call: git, argv array, no shell, stdin ignored, bounded.
755
+ *
756
+ * @param {Io} io
757
+ * @param {readonly string[]} args
758
+ * @param {number} timeout
759
+ * @param {number} maxBuffer
760
+ * @returns {GitResult}
761
+ */
762
+ function git(io, args, timeout, maxBuffer) {
763
+ const res = io.exec('git', [...args], {
764
+ cwd: io.cwd,
765
+ env: io.env,
766
+ stdio: ['ignore', 'pipe', 'pipe'],
767
+ timeout,
768
+ maxBuffer,
769
+ windowsHide: true,
770
+ shell: false,
771
+ });
772
+ const errored = Boolean(res && res.error);
773
+ const status = res && typeof res.status === 'number' ? res.status : null;
774
+ return {
775
+ ok: !errored && status === 0,
776
+ answered: !errored && status !== null,
777
+ stdout: asBuffer(res && res.stdout).toString('utf8'),
778
+ };
779
+ }
780
+
781
+ /**
782
+ * A commit revision for git's argv: HEAD, a tag under refs/tags/ (so a branch
783
+ * that shares a release tag's name is never picked), or a gated SHA prefix —
784
+ * each peeled to a commit.
785
+ *
786
+ * @param {{ kind: 'head' } | { kind: 'tag', tag: string } | { kind: 'sha', sha: string }} ref
787
+ * @returns {string}
788
+ */
789
+ function commitRev(ref) {
790
+ if (ref.kind === 'head') return 'HEAD^{commit}';
791
+ if (ref.kind === 'tag') return 'refs/tags/' + ref.tag + '^{commit}';
792
+ return ref.sha + '^{commit}';
793
+ }
794
+
795
+ /**
796
+ * A range between two full SHAs git printed.
797
+ *
798
+ * @param {string} base
799
+ * @param {string} head
800
+ * @returns {string}
801
+ */
802
+ function rangeRev(base, head) {
803
+ if (!FULL_SHA_RE.test(base) || !FULL_SHA_RE.test(head)) throw new Error('rangeRev: not a full SHA');
804
+ return base + '..' + head;
805
+ }
806
+
807
+ /**
808
+ * Resolve a revision to its full commit SHA.
809
+ *
810
+ * @param {Io} io
811
+ * @param {string} rev from commitRev()
812
+ * @returns {{ kind: 'sha', sha: string } | { kind: 'unknown' } | { kind: 'failed' }}
813
+ */
814
+ function resolveCommit(io, rev) {
815
+ const r = git(io, ['rev-parse', '--verify', '--quiet', rev], GIT_SHORT_TIMEOUT_MS, LINE_MAX_BUFFER);
816
+ if (!r.answered) return { kind: 'failed' };
817
+ if (!r.ok) return { kind: 'unknown' };
818
+ const sha = r.stdout.endsWith('\n') ? r.stdout.slice(0, -1) : r.stdout;
819
+ return FULL_SHA_RE.test(sha) ? { kind: 'sha', sha } : { kind: 'failed' };
820
+ }
821
+
822
+ /**
823
+ * Read a regular file of at most `maxBytes` without following a final symlink,
824
+ * or null. The lstat decides before any open (a FIFO, directory, device or
825
+ * symlink is refused unopened); the open adds O_NONBLOCK and O_NOFOLLOW, and the
826
+ * fstat re-checks what was opened. The read loop is bounded by the byte count.
827
+ *
828
+ * @param {string} filePath
829
+ * @param {number} maxBytes
830
+ * @returns {Buffer | null}
831
+ */
832
+ function readBoundedRegularFile(filePath, maxBytes) {
833
+ let st;
834
+ try {
835
+ st = fs.lstatSync(filePath);
836
+ } catch (_) {
837
+ return null;
838
+ }
839
+ if (!st.isFile() || st.size > maxBytes) return null;
840
+ let fd;
841
+ try {
842
+ fd = fs.openSync(filePath, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0) | (fs.constants.O_NOFOLLOW || 0));
843
+ } catch (_) {
844
+ return null;
845
+ }
846
+ try {
847
+ const fst = fs.fstatSync(fd);
848
+ if (!fst.isFile() || fst.size > maxBytes) return null;
849
+ // One byte past the stat'd size: a file that grew after the stat is refused.
850
+ const buf = Buffer.alloc(fst.size + 1);
851
+ let total = 0;
852
+ for (let i = 0; i < buf.length; i++) {
853
+ const n = fs.readSync(fd, buf, total, buf.length - total, null);
854
+ if (n === 0) break;
855
+ total += n;
856
+ if (total === buf.length) break;
857
+ }
858
+ return total > fst.size ? null : buf.subarray(0, total);
859
+ } catch (_) {
860
+ return null;
861
+ } finally {
862
+ try { fs.closeSync(fd); } catch (_) { /* the read already decided */ }
863
+ }
864
+ }
865
+
866
+ // ---------------------------------------------------------------------------
867
+ // Subcommands
868
+ // ---------------------------------------------------------------------------
869
+
870
+ /**
871
+ * @param {Io} io
872
+ * @param {number} code
873
+ * @param {string} reason a REASONS value
874
+ * @returns {Outcome}
875
+ */
876
+ function refuse(io, code, reason) {
877
+ io.stderr('release-trace: ' + reason + '\n');
878
+ return { code, stdout: '' };
879
+ }
880
+
881
+ /**
882
+ * D-TRACE-LAST-TAG: the highest release tag merged into HEAD.
883
+ *
884
+ * @param {Io} io
885
+ * @returns {Outcome}
886
+ */
887
+ function runLastTag(io) {
888
+ const r = git(io, ['for-each-ref', '--merged=HEAD', '--format=%(refname)%00', 'refs/tags/'],
889
+ GIT_SHORT_TIMEOUT_MS * 2, TAGS_MAX_BUFFER);
890
+ if (!r.ok) return refuse(io, EXIT_CODES.GIT_FAILURE, REASONS.NO_HEAD);
891
+ const names = parseTagRefs(r.stdout);
892
+ if (names === null) return refuse(io, EXIT_CODES.GIT_FAILURE, REASONS.GIT_FAILED);
893
+ const tag = selectLastTag(names);
894
+ return { code: EXIT_CODES.OK, stdout: 'LAST_TAG ' + (tag === null ? 'none' : tag) + '\n' };
895
+ }
896
+
897
+ /**
898
+ * D-TRACE-PATHS: the path-log flags. Each pins a behaviour a repository's own
899
+ * config could otherwise change: no rename pairing (a rename INTO CHANGELOG.md
900
+ * still lists the source path it deleted), no relative-path filter
901
+ * (`diff.relative`), no ignored submodules, no external diff or textconv, the
902
+ * first-parent diff for merges, and a root commit's paths (`log.showRoot`).
903
+ */
904
+ const PATH_LOG_FLAGS = Object.freeze([
905
+ '--first-parent', '--no-color', '--no-show-signature', '--no-notes', '--no-renames', '--no-relative',
906
+ '--no-ext-diff', '--no-textconv', '--ignore-submodules=none', '--diff-merges=first-parent',
907
+ '--name-only', '-z', '--format=%x00%H',
908
+ ]);
909
+
910
+ /**
911
+ * D-TRACE-MAILMAP: the message-log flags. A repository's `.mailmap` is committed
912
+ * content, and honouring it would let a later commit re-attribute an earlier one
913
+ * to a `[bot]` identity. The format therefore reads the RAW ident — `%an`/`%ae`,
914
+ * never `%aN`/`%aE`, which apply the mailmap (git 2.50 leaves `%an` raw even under
915
+ * `--use-mailmap`) — and `--no-use-mailmap` also disarms `log.mailmap` for a git
916
+ * that rewrites the ident itself.
917
+ *
918
+ * D-TRACE-FULL-MESSAGE: references are scanned in `%B`, the raw message, never in
919
+ * `%s` + `%b`. git folds a wrapped subject paragraph onto ONE `%s` line, so a
920
+ * keyword ending the subject's first line and a reference opening its second
921
+ * would read as one line here and as two to step 3a, which reads each message as
922
+ * `git log --format=%B` lines — the script would trace a commit whose reference
923
+ * the gather step never collects. `%s` stays for the exempt rules, which match
924
+ * the subject as git renders it. The parity test pins both sides.
925
+ */
926
+ const MESSAGE_LOG_FLAGS = Object.freeze([
927
+ '--first-parent', '--no-color', '--no-show-signature', '--no-notes', '--no-use-mailmap', '--encoding=UTF-8',
928
+ '--format=%H%x00%an%x00%ae%x00%s%x00%B%x1e',
929
+ ]);
930
+
931
+ /**
932
+ * D-TRACE-SCAN: trace the first-parent commits of `<from>..HEAD` (D4).
933
+ *
934
+ * HEAD and --from are each resolved to a full SHA ONCE, and every later call
935
+ * walks that pinned range, so a commit landing mid-run cannot shift the lists.
936
+ * rev-list names the commits (at most SCAN_BOUND + 1; seeing the extra one is
937
+ * `bound:hit`); the two logs must describe exactly those, in that order.
938
+ *
939
+ * @param {Io} io
940
+ * @param {{ from: string, grammar: Grammar, key: string | null, tracedFile: string | null }} args
941
+ * @param {MainDeps} deps
942
+ * @returns {Outcome}
943
+ */
944
+ function runMap(io, args, deps) {
945
+ /** @type {{ kind: 'tag', tag: string } | { kind: 'sha', sha: string } | null} */
946
+ const fromRef = RELEASE_TAG_RE.test(args.from) ? { kind: 'tag', tag: args.from }
947
+ : HEX_REF_RE.test(args.from) ? { kind: 'sha', sha: args.from } : null;
948
+ if (fromRef === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, REASONS.BAD_REF);
949
+ const key = args.key === null ? null : asciiUpper(args.key);
950
+ if (key !== null && !KEY_RE.test(key)) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, REASONS.BAD_KEY);
951
+
952
+ /** @type {Set<string>} */
953
+ let traced = new Set();
954
+ if (args.tracedFile !== null) {
955
+ const bytes = readBoundedRegularFile(path.resolve(io.cwd, args.tracedFile), LIMITS.MAX_TRACED_BYTES);
956
+ const parsed = bytes === null ? null : parseTracedFile(bytes);
957
+ if (parsed === null) return refuse(io, EXIT_CODES.INPUT_UNUSABLE, REASONS.BAD_TRACED_FILE);
958
+ traced = parsed;
959
+ }
960
+
961
+ const head = resolveCommit(io, commitRev({ kind: 'head' }));
962
+ if (head.kind !== 'sha') return refuse(io, EXIT_CODES.GIT_FAILURE, REASONS.NO_HEAD);
963
+ const base = resolveCommit(io, commitRev(fromRef));
964
+ if (base.kind === 'failed') return refuse(io, EXIT_CODES.GIT_FAILURE, REASONS.GIT_FAILED);
965
+ // A SHA prefix must resolve to a commit it prefixes — never to a ref that
966
+ // happens to be spelled in hex.
967
+ if (base.kind === 'unknown' || (fromRef.kind === 'sha' && !base.sha.startsWith(fromRef.sha))) {
968
+ return refuse(io, EXIT_CODES.INPUT_UNUSABLE, REASONS.UNKNOWN_REF);
969
+ }
970
+ const range = rangeRev(base.sha, head.sha);
971
+
972
+ const walk = git(io, ['rev-list', '--first-parent', '--max-count=' + (LIMITS.SCAN_BOUND + 1), range],
973
+ GIT_WALK_TIMEOUT_MS, REV_LIST_MAX_BUFFER);
974
+ const listed = walk.ok ? parseShaLines(walk.stdout) : null;
975
+ if (listed === null || listed.length > LIMITS.SCAN_BOUND + 1) return refuse(io, EXIT_CODES.GIT_FAILURE, REASONS.GIT_FAILED);
976
+ const bound = listed.length > LIMITS.SCAN_BOUND ? 'hit' : 'ok';
977
+ const shas = listed.slice(0, LIMITS.SCAN_BOUND);
978
+
979
+ /** @type {TraceRecord[]} */
980
+ const records = [];
981
+ if (shas.length > 0) {
982
+ const count = '--max-count=' + shas.length;
983
+ const messages = git(io, ['log', count, ...MESSAGE_LOG_FLAGS, range], GIT_WALK_TIMEOUT_MS, LOG_MAX_BUFFER);
984
+ const parsedMessages = messages.ok ? parseMessageLog(messages.stdout, shas) : null;
985
+ const pathLog = git(io, ['-c', 'log.showRoot=true', 'log', count, ...PATH_LOG_FLAGS, range],
986
+ GIT_WALK_TIMEOUT_MS, LOG_MAX_BUFFER);
987
+ const parsedPaths = pathLog.ok ? parsePathLog(pathLog.stdout, shas) : null;
988
+ if (parsedMessages === null || parsedPaths === null) return refuse(io, EXIT_CODES.GIT_FAILURE, REASONS.GIT_FAILED);
989
+ /** @type {ClassifyContext} */
990
+ const ctx = { grammar: args.grammar, key, traced };
991
+ for (let k = 0; k < shas.length; k++) {
992
+ const commit = { ...parsedMessages[k], paths: parsedPaths[k] };
993
+ records.push({ sha: commit.sha, cls: classify(commit, ctx), author: commit.name });
994
+ }
995
+ }
996
+
997
+ const scannedSet = new Set(shas);
998
+ let unmatched = 0;
999
+ for (const sha of traced) if (!scannedSet.has(sha)) unmatched++;
1000
+
1001
+ /** @type {TraceSummary} */
1002
+ const summary = { from: args.from, bound, unmatched, records };
1003
+ const compose = typeof deps.render === 'function' ? deps.render : render;
1004
+ const text = compose(summary);
1005
+ if (!checkTraceOutput(text, { from: args.from, scanned: shas.length, bound, unmatched })) {
1006
+ return refuse(io, EXIT_CODES.OUTPUT_GATE_REFUSED, REASONS.GATE_REFUSED);
1007
+ }
1008
+ return { code: EXIT_CODES.OK, stdout: /** @type {string} */ (text) };
1009
+ }
1010
+
1011
+ // ---------------------------------------------------------------------------
1012
+ // main — returns {code, stdout}; never calls process.exit
1013
+ // ---------------------------------------------------------------------------
1014
+
1015
+ /**
1016
+ * The production exec: spawnSync, read off the module object at call time.
1017
+ *
1018
+ * @type {ExecFn}
1019
+ */
1020
+ function defaultExec(file, args, opts) {
1021
+ return childProcess.spawnSync(file, args, /** @type {any} */ (opts));
1022
+ }
1023
+
1024
+ /**
1025
+ * A short, safe label for a thrown value — its class name, never its message (a
1026
+ * message can quote input bytes).
1027
+ *
1028
+ * @param {unknown} err
1029
+ * @returns {string}
1030
+ */
1031
+ function errorLabel(err) {
1032
+ const e = /** @type {any} */ (err);
1033
+ return (e && typeof e.name === 'string' ? e.name : 'unknown').replace(/[^A-Za-z0-9_]/g, '').slice(0, 40);
1034
+ }
1035
+
1036
+ /**
1037
+ * @param {readonly string[]} argv process.argv
1038
+ * @param {MainDeps} [deps]
1039
+ * @returns {Outcome}
1040
+ */
1041
+ function main(argv, deps) {
1042
+ const d = deps || {};
1043
+ /** @type {Io} */
1044
+ const io = {
1045
+ exec: typeof d.exec === 'function' ? d.exec : defaultExec,
1046
+ env: Object.assign({}, process.env, { GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0' }),
1047
+ cwd: typeof d.cwd === 'string' ? d.cwd : process.cwd(),
1048
+ stderr: typeof d.stderr === 'function' ? d.stderr : (text => { process.stderr.write(text); }),
1049
+ };
1050
+ const args = parseArgs(argv);
1051
+ if (args.kind === 'usage') {
1052
+ io.stderr(USAGE + '\n');
1053
+ return { code: EXIT_CODES.USAGE, stdout: '' };
1054
+ }
1055
+ try {
1056
+ return args.kind === 'last-tag' ? runLastTag(io) : runMap(io, args, d);
1057
+ } catch (err) {
1058
+ // An injected exec that throws, or a broken invariant: no trace.
1059
+ return refuse(io, EXIT_CODES.GIT_FAILURE, REASONS.INTERNAL + ' (' + errorLabel(err) + ')');
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * D-TRACE-STDOUT: settle whatever main() returned into what the boundary may
1065
+ * print. A non-zero code prints nothing; code 0 prints only a LAST_TAG line or a
1066
+ * `map` output that passes the gate. Anything else prints nothing and exits 4
1067
+ * (malformed outcome) or 5 (a success whose text fails its gate).
1068
+ *
1069
+ * @param {unknown} outcome
1070
+ * @returns {Outcome}
1071
+ */
1072
+ function settleOutcome(outcome) {
1073
+ const o = /** @type {any} */ (outcome);
1074
+ const failed = { code: EXIT_CODES.GIT_FAILURE, stdout: '' };
1075
+ if (o === null || typeof o !== 'object' || typeof o.stdout !== 'string') return failed;
1076
+ if (!Object.values(EXIT_CODES).includes(o.code)) return failed;
1077
+ if (o.code !== EXIT_CODES.OK) return { code: o.code, stdout: '' };
1078
+ const lastTag = o.stdout.endsWith('\n') && LAST_TAG_LINE_RE.test(o.stdout.slice(0, -1));
1079
+ if (lastTag || checkTraceOutput(o.stdout)) return { code: o.code, stdout: o.stdout };
1080
+ return { code: EXIT_CODES.OUTPUT_GATE_REFUSED, stdout: '' };
1081
+ }
1082
+
1083
+ // ---------------------------------------------------------------------------
1084
+ // Top-level boundary — the ONLY stdout write and exitCode assignment
1085
+ //
1086
+ // A write that fails (a closed pipe, an unwritable descriptor) sets exit 3,
1087
+ // whether it fails synchronously, through the write callback, or as a stream
1088
+ // 'error' event — which would otherwise crash the process with exit 1.
1089
+ // ---------------------------------------------------------------------------
1090
+
1091
+ if (require.main === module) {
1092
+ let outcome;
1093
+ try {
1094
+ outcome = main(process.argv);
1095
+ } catch (err) {
1096
+ process.stderr.write('release-trace: ' + REASONS.INTERNAL + ' (' + errorLabel(err) + ')\n');
1097
+ outcome = { code: EXIT_CODES.GIT_FAILURE, stdout: '' };
1098
+ }
1099
+ const settled = settleOutcome(outcome);
1100
+ process.exitCode = settled.code;
1101
+ if (settled.stdout !== '') {
1102
+ const writeFailed = () => { process.exitCode = EXIT_CODES.WRITE_FAILED; };
1103
+ process.stdout.on('error', writeFailed);
1104
+ try {
1105
+ process.stdout.write(settled.stdout, err => { if (err) writeFailed(); });
1106
+ } catch (_) {
1107
+ writeFailed();
1108
+ }
1109
+ }
1110
+ }
1111
+
1112
+ // ---------------------------------------------------------------------------
1113
+ // Exports — the unit tests and the parity test are the consumers
1114
+ // ---------------------------------------------------------------------------
1115
+
1116
+ module.exports = Object.freeze({
1117
+ EXIT_CODES,
1118
+ LIMITS,
1119
+ RELEASE_TAG_RE,
1120
+ KEYWORD_RE,
1121
+ TRAILING_CLASS,
1122
+ GRAMMARS,
1123
+ GRAMMAR_RULES,
1124
+ EXEMPT,
1125
+ CLASSES,
1126
+ AUTHOR_RE,
1127
+ TRACE_HEADER_RE,
1128
+ TRACE_ENTRY_RE,
1129
+ TRACE_MORE_RE,
1130
+ LAST_TAG_LINE_RE,
1131
+ REASONS,
1132
+ MESSAGE_LOG_FLAGS,
1133
+ selectLastTag,
1134
+ findReference,
1135
+ classify,
1136
+ render,
1137
+ checkTraceOutput,
1138
+ parseMessageLog,
1139
+ parsePathLog,
1140
+ parseArgs,
1141
+ main,
1142
+ settleOutcome,
1143
+ });