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.
- package/CHANGELOG.md +156 -0
- package/README.md +86 -18
- package/dist/agents/git.md +824 -0
- package/dist/cli/commands/agents.js +6 -1
- package/dist/cli/commands/attribution-prompts.js +1 -1
- package/dist/cli/commands/compliance-prompts.js +1 -1
- package/dist/cli/commands/compliance.js +23 -1
- package/dist/cli/commands/init-seed.js +24 -26
- package/dist/cli/commands/init.js +502 -71
- package/dist/cli/commands/install-report.js +205 -0
- package/dist/cli/commands/knowledge/index.js +2 -2
- package/dist/cli/commands/knowledge/toggle.js +27 -37
- package/dist/cli/commands/learning.js +37 -30
- package/dist/cli/commands/memory.js +79 -69
- package/dist/cli/commands/prompt-io.js +4 -4
- package/dist/cli/commands/security.js +76 -16
- package/dist/cli/commands/skills.js +53 -7
- package/dist/cli/commands/tracker-prompts.js +145 -0
- package/dist/cli/commands/tracker.js +405 -0
- package/dist/cli/commands/uninstall.js +211 -65
- package/dist/cli.js +2 -0
- package/dist/commands/bug-analysis.md +22 -4
- package/dist/commands/code-review.md +44 -15
- package/dist/commands/debug.md +20 -6
- package/dist/commands/dynamic-build.md +289 -67
- package/dist/commands/dynamic-plan.md +60 -21
- package/dist/commands/dynamic-profile.md +1 -1
- package/dist/commands/dynamic-tickets.md +58 -8
- package/dist/commands/explore.md +2 -2
- package/dist/commands/implement.md +241 -53
- package/dist/commands/plan.md +88 -17
- package/dist/commands/release.md +64 -17
- package/dist/commands/resolve.md +138 -58
- package/dist/commands/self-review.md +2 -2
- package/dist/core/agent-models.js +55 -12
- package/dist/core/assets.js +58 -2
- package/dist/core/evidence-policy.js +147 -0
- package/dist/core/feature-config.js +130 -64
- package/dist/core/feature-switch.js +112 -0
- package/dist/core/flags.js +4 -4
- package/dist/core/manifest.js +33 -7
- package/dist/core/mds-variants.js +861 -0
- package/dist/core/model-discovery.js +12 -1
- package/dist/core/plugins.js +357 -9
- package/dist/core/project-paths.js +1 -1
- package/dist/core/proxy-log.js +8 -6
- package/dist/core/proxy-state.js +11 -8
- package/dist/core/reference-sweep.js +136 -0
- package/dist/core/tracker.js +407 -0
- package/dist/skills/git/references/decision-markers.md +19 -0
- package/dist/skills/git/references/learn-conventions.md +56 -0
- package/dist/skills/git/references/pr/check-ci-status.md +14 -0
- package/dist/skills/git/references/pr/check-merge-readiness.md +28 -0
- package/dist/skills/git/references/pr/ensure-pr-ready.md +24 -0
- package/dist/skills/git/references/pr/fetch-review-threads.md +22 -0
- package/dist/skills/git/references/pr/post-resolution-summary.md +40 -0
- package/dist/skills/git/references/pr/post-review-summary.md +42 -0
- package/dist/skills/git/references/pr/resolve-review-threads.md +35 -0
- package/dist/skills/git/references/pr/update-pr-evidence.md +14 -0
- package/dist/skills/git/references/pr/validate-branch.md +18 -0
- package/dist/skills/git/references/publication-gate.md +13 -0
- package/dist/skills/git/references/tracker/_mcp.md +153 -0
- package/dist/skills/git/references/tracker/github/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/github/backlink-shipped-issues.md +40 -0
- package/dist/skills/git/references/tracker/github/create-release.md +11 -0
- package/dist/skills/git/references/tracker/github/ensure-pr-ready.md +16 -0
- package/dist/skills/git/references/tracker/github/ensure-traceable-issue.md +69 -0
- package/dist/skills/git/references/tracker/github/fetch-issue.md +32 -0
- package/dist/skills/git/references/tracker/github/fetch-issues-batch.md +17 -0
- package/dist/skills/git/references/tracker/github/gather-release-evidence.md +19 -0
- package/dist/skills/git/references/tracker/github/manage-debt.md +101 -0
- package/dist/skills/git/references/tracker/github/post-wave-report.md +28 -0
- package/dist/skills/git/references/tracker/github/setup-task.md +26 -0
- package/dist/skills/git/references/tracker/jira/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/jira/backlink-shipped-issues.md +49 -0
- package/dist/skills/git/references/tracker/jira/create-release.md +17 -0
- package/dist/skills/git/references/tracker/jira/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/jira/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/jira/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/jira/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/jira/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/jira/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/jira/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/jira/setup-task.md +31 -0
- package/dist/skills/git/references/tracker/linear/associate-release.md +18 -0
- package/dist/skills/git/references/tracker/linear/backlink-shipped-issues.md +53 -0
- package/dist/skills/git/references/tracker/linear/create-release.md +17 -0
- package/dist/skills/git/references/tracker/linear/ensure-pr-ready.md +22 -0
- package/dist/skills/git/references/tracker/linear/ensure-traceable-issue.md +53 -0
- package/dist/skills/git/references/tracker/linear/fetch-issue.md +14 -0
- package/dist/skills/git/references/tracker/linear/fetch-issues-batch.md +15 -0
- package/dist/skills/git/references/tracker/linear/gather-release-evidence.md +18 -0
- package/dist/skills/git/references/tracker/linear/manage-debt.md +37 -0
- package/dist/skills/git/references/tracker/linear/post-wave-report.md +33 -0
- package/dist/skills/git/references/tracker/linear/setup-task.md +32 -0
- package/dist/skills/git/references/trust-rule.md +7 -0
- package/dist/targets/claude-code/installer.js +1213 -31
- package/dist/targets/claude-code/legacy.js +5 -0
- package/dist/targets/claude-code/post-install.js +196 -74
- package/dist/targets/claude-code/tracker-install.js +161 -0
- package/package.json +4 -3
- package/src/assets/agents/code.md +42 -4
- package/src/assets/agents/design.md +1 -1
- package/src/assets/agents/git.mds +827 -0
- package/src/assets/agents/knowledge.md +1 -1
- package/src/assets/agents/learning.md +11 -0
- package/src/assets/agents/synthesize.md +1 -1
- package/src/assets/agents/test.md +16 -5
- package/src/assets/agents/tracker.md +467 -0
- package/src/assets/agents/validate.md +7 -5
- package/src/assets/commands/_partials/_engine.mds +11 -9
- package/src/assets/commands/_partials/_evidence_policy.mds +30 -0
- package/src/assets/commands/_partials/_knowledge.mds +2 -2
- package/src/assets/commands/_partials/_plan_contract.mds +22 -7
- package/src/assets/commands/_partials/_preamble.mds +1 -1
- package/src/assets/commands/_partials/_publication.mds +3 -1
- package/src/assets/commands/_partials/_ticket_template.mds +3 -2
- package/src/assets/commands/_partials/_tracker.mds +18 -0
- package/src/assets/commands/_partials/_wave.mds +16 -10
- package/src/assets/commands/bug-analysis.mds +15 -5
- package/src/assets/commands/code-review.mds +34 -14
- package/src/assets/commands/debug.mds +11 -4
- package/src/assets/commands/dynamic-build.mds +227 -41
- package/src/assets/commands/dynamic-plan.mds +35 -13
- package/src/assets/commands/dynamic-tickets.mds +47 -5
- package/src/assets/commands/implement.mds +206 -52
- package/src/assets/commands/plan.mds +70 -17
- package/src/assets/commands/release.md +64 -17
- package/src/assets/commands/resolve.mds +126 -56
- package/src/assets/mds/git/_pr.mds +331 -0
- package/src/assets/mds/git/_references.mds +135 -0
- package/src/assets/mds/tracker/_common.mds +156 -0
- package/src/assets/mds/tracker/_github.mds +472 -0
- package/src/assets/mds/tracker/_jira.mds +407 -0
- package/src/assets/mds/tracker/_linear.mds +449 -0
- package/src/assets/mds/tracker/_mcp.mds +299 -0
- package/src/assets/scripts/hooks/assets/orchestrator-charter.md +5 -8
- package/src/assets/scripts/hooks/background-memory-update +14 -9
- package/src/assets/scripts/hooks/capture-prompt +6 -2
- package/src/assets/scripts/hooks/capture-question +6 -2
- package/src/assets/scripts/hooks/capture-turn +6 -2
- package/src/assets/scripts/hooks/ensure-devflow-init +1 -1
- package/src/assets/scripts/hooks/ensure-root-gitignore +161 -60
- package/src/assets/scripts/hooks/hook-log-init +3 -1
- package/src/assets/scripts/hooks/json-helper.cjs +223 -5
- package/src/assets/scripts/hooks/lib/project-paths.cjs +1 -1
- package/src/assets/scripts/hooks/memory-worker +15 -8
- package/src/assets/scripts/hooks/pre-compact-memory +12 -8
- package/src/assets/scripts/hooks/preamble +1 -4
- package/src/assets/scripts/hooks/queue-append +68 -24
- package/src/assets/scripts/hooks/session-start-context +355 -8
- package/src/assets/scripts/hooks/session-start-memory +12 -8
- package/src/assets/scripts/pr-evidence.cjs +1961 -0
- package/src/assets/scripts/redact-secrets.cjs +490 -62
- package/src/assets/scripts/release-trace.cjs +1143 -0
- package/src/assets/scripts/resolve-evidence-policy.cjs +1065 -0
- package/src/assets/scripts/verify-evidence.cjs +1822 -0
- package/src/assets/skills/compliance/SKILL.md +2 -0
- package/src/assets/skills/docs-framework/SKILL.md +5 -3
- package/src/assets/skills/git/SKILL.md +8 -78
- package/src/assets/skills/git/references/github-api.md +179 -141
- package/src/assets/skills/git/references/patterns.md +11 -6
- package/src/assets/skills/review-methodology/SKILL.md +1 -1
- package/src/assets/skills/review-methodology/references/patterns.md +6 -61
- package/src/assets/skills/review-methodology/references/violations.md +14 -22
- 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
|
+
});
|