flagrm 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/README.md +56 -29
  3. package/dist/adapters/angular/component-files.js +41 -0
  4. package/dist/adapters/angular/flow.js +121 -0
  5. package/dist/adapters/angular/holders.js +194 -0
  6. package/dist/adapters/angular/index.js +20 -1
  7. package/dist/adapters/angular/inputs.js +181 -0
  8. package/dist/cli.js +41 -21
  9. package/dist/core/baseline.js +60 -31
  10. package/dist/core/change-filter.js +3 -2
  11. package/dist/core/config-snapshot.js +26 -3
  12. package/dist/core/config.js +5 -1
  13. package/dist/core/detect.js +4 -4
  14. package/dist/core/doctor.js +26 -6
  15. package/dist/core/environment.js +25 -3
  16. package/dist/core/git.js +50 -3
  17. package/dist/core/hook.js +13 -2
  18. package/dist/core/install.js +26 -11
  19. package/dist/core/inventory.js +3 -1
  20. package/dist/core/markdown.js +4 -0
  21. package/dist/core/name-owner.js +179 -0
  22. package/dist/core/report.js +7 -0
  23. package/dist/core/run-cache.js +147 -0
  24. package/dist/core/scan.js +30 -3
  25. package/dist/core/scope-files.js +14 -0
  26. package/dist/core/scope.js +3 -3
  27. package/dist/core/test-runner.js +136 -0
  28. package/dist/core/types.js +9 -4
  29. package/dist/core/verify/commands.js +160 -22
  30. package/dist/core/verify/coverage.js +92 -0
  31. package/dist/core/verify/index.js +32 -5
  32. package/dist/core/verify/log-results.js +127 -0
  33. package/dist/core/verify/removed-tests.js +90 -7
  34. package/dist/core/verify/test-results.js +58 -10
  35. package/package.json +4 -4
  36. package/skills/flagrm-remove/SKILL.md +28 -8
  37. package/skills/flagrm-remove/batch.md +38 -0
  38. package/skills/flagrm-verify/SKILL.md +12 -6
@@ -2,19 +2,23 @@
2
2
  * Why each baseline test no longer runs. Deleting the OFF path's tests and
3
3
  * dropping the flag from test names is part of a removal, and a narrower test
4
4
  * command leaves tests out on purpose; only the rest needs explaining.
5
+ * Without result files, the changed test files' declarations stand in for the
6
+ * names (`declaredTestDiff`).
5
7
  */
6
8
  import fs from "node:fs";
7
9
  import path from "node:path";
8
10
  import { flagWords, words } from "../flag-words.js";
9
11
  import { fileAt } from "../git.js";
10
- import { escapeRegExp, isTestPath } from "../scan.js";
12
+ import { computeCodeMask, escapeRegExp, isTestPath } from "../scan.js";
11
13
  import { JUNIT_SEPARATOR, testKey } from "./test-results.js";
12
- /** Sort the tests that no longer run (`removed`) using the new ones (`added`) and the diff. */
13
- export function classifyRemovedTests(removed, added, afterNames, ctx) {
14
+ /**
15
+ * Sort the tests that no longer run (`removed`) using the new ones (`added`)
16
+ * and the diff. `files`: the changed test files when the caller already read them.
17
+ */
18
+ export function classifyRemovedTests(removed, added, afterNames, ctx, files = changedTestFiles(ctx)) {
14
19
  const out = { deleted: [], renamed: [], excludedByConfig: [], unexplained: [] };
15
20
  const renamedTo = matchRenames(removed, added, flagWords(ctx.names));
16
21
  const stillRuns = new Set(afterNames.map(testKey));
17
- const files = changedTestFiles(ctx);
18
22
  for (const name of removed) {
19
23
  const to = renamedTo.get(testKey(name));
20
24
  if (to)
@@ -83,7 +87,7 @@ function matchRenames(removed, added, flag) {
83
87
  }
84
88
  return out;
85
89
  }
86
- function changedTestFiles(ctx) {
90
+ function changedTestFiles(ctx, includeNew = false) {
87
91
  if (!ctx.sha)
88
92
  return [];
89
93
  const out = [];
@@ -91,7 +95,7 @@ function changedTestFiles(ctx) {
91
95
  if (!isTestPath(rel))
92
96
  continue;
93
97
  const before = fileAt(ctx.root, ctx.sha, rel);
94
- if (before === undefined)
98
+ if (before === undefined && !includeNew)
95
99
  continue;
96
100
  let now;
97
101
  try {
@@ -100,7 +104,7 @@ function changedTestFiles(ctx) {
100
104
  catch {
101
105
  now = undefined;
102
106
  }
103
- out.push({ before, now });
107
+ out.push({ rel, before: before ?? "", now });
104
108
  }
105
109
  return out;
106
110
  }
@@ -123,6 +127,10 @@ function isDeleted(name, keyStillRuns, files) {
123
127
  }
124
128
  /** xUnit (`InlineData`, `[Bit]AutoData(...)` with values), NUnit and MSTest attributes that each add a data row. */
125
129
  const DATA_ROW = /\b(?:\w*InlineData|\w*AutoData|TestCase|DataRow)\s*\(/g;
130
+ /** Whether a test file has data-row attributes or `.each` cases. */
131
+ function hasDataRows(text) {
132
+ return new RegExp(DATA_ROW.source).test(text) || /\.each\s*[(`]/.test(text);
133
+ }
126
134
  /**
127
135
  * How many data rows `own` has in `text`: the row attributes between the end
128
136
  * of the previous member and its declaration. For JUnit names (`it.each`
@@ -139,3 +147,78 @@ function dataRows(text, own, junit) {
139
147
  }
140
148
  return rows;
141
149
  }
150
+ // --- declarations ----------------------------------------------------------------
151
+ const JS_TEST = /(?<![\w$.])(describe|fdescribe|xdescribe|context|suite|it|fit|xit|test)(?:\.(?:only|skip|todo))?(?:\.each\s*(?:\([\s\S]*?\)|`[\s\S]*?`))?\s*\(\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2/g;
152
+ /** `it`/`test` titles prefixed with the titles of the `describe` blocks around them (by brace depth over code). */
153
+ function jsTests(text) {
154
+ const code = computeCodeMask(text);
155
+ const out = [];
156
+ const open = [];
157
+ let depth = 0;
158
+ let pos = 0;
159
+ for (const m of text.matchAll(JS_TEST)) {
160
+ if (!code[m.index])
161
+ continue;
162
+ for (; pos < m.index; pos++)
163
+ if (code[pos])
164
+ depth += text[pos] === "{" ? 1 : text[pos] === "}" ? -1 : 0;
165
+ while (open.length && open[open.length - 1].depth >= depth)
166
+ open.pop();
167
+ if (/describe|context|suite/.test(m[1]))
168
+ open.push({ title: m[3], depth });
169
+ else
170
+ out.push([...open.map((d) => d.title), m[3]].join(JUNIT_SEPARATOR));
171
+ }
172
+ return out;
173
+ }
174
+ const CS_TEST = /\[(?:Fact|Theory|Test|TestCase|TestMethod)\b[^\]]*\][\s\S]*?\b(?:void|Task|ValueTask)(?:<[^>]*>)?\s+(\w+)\s*\(/g;
175
+ /** Test methods as TRX names them without data-row arguments: `Namespace.Class.Method`. */
176
+ function csTests(text) {
177
+ const ns = /^\s*namespace\s+([\w.]+)/m.exec(text)?.[1];
178
+ return [...text.matchAll(CS_TEST)].map((m) => {
179
+ const cls = [...text.slice(0, m.index).matchAll(/\bclass\s+(\w+)/g)].pop()?.[1];
180
+ return [ns, cls, m[1]].filter(Boolean).join(".");
181
+ });
182
+ }
183
+ /** The tests a test file declares, named as result files name them where the syntax allows. */
184
+ export function declaredTests(text, file) {
185
+ if (/\.[cm]?[jt]sx?$/.test(file))
186
+ return jsTests(text);
187
+ if (file.endsWith(".cs"))
188
+ return csTests(text);
189
+ if (file.endsWith(".go"))
190
+ return [...text.matchAll(/\bfunc (Test\w+)\s*\(/g)].map((m) => m[1]);
191
+ if (file.endsWith(".py"))
192
+ return [...text.matchAll(/^\s*def (test_\w+)\s*\(/gm)].map((m) => m[1]);
193
+ return [];
194
+ }
195
+ /** `from` without the names in `other`, counting duplicates. */
196
+ function minus(from, other) {
197
+ const left = new Map();
198
+ for (const name of other)
199
+ left.set(name, (left.get(name) ?? 0) + 1);
200
+ return from.filter((name) => {
201
+ const n = left.get(name) ?? 0;
202
+ left.set(name, n - 1);
203
+ return n <= 0;
204
+ });
205
+ }
206
+ /**
207
+ * What the changed test files declared at the baseline and don't now, the
208
+ * new declarations, and all declared now: the stand-in for result files'
209
+ * names when there are none.
210
+ */
211
+ export function declaredTestDiff(ctx) {
212
+ const before = [];
213
+ const now = [];
214
+ let rows = false;
215
+ // A file new since the baseline declared nothing then, so a moved spec's tests cancel out.
216
+ const files = changedTestFiles(ctx, true);
217
+ for (const file of files) {
218
+ rows ||= hasDataRows(file.before) || (file.now !== undefined && hasDataRows(file.now));
219
+ before.push(...declaredTests(file.before, file.rel));
220
+ if (file.now !== undefined)
221
+ now.push(...declaredTests(file.now, file.rel));
222
+ }
223
+ return { removed: minus(before, now), added: minus(now, before), now, rows, files };
224
+ }
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Read test counts and names from the result files a test command writes:
3
- * TRX (`dotnet test --logger trx`) and JUnit XML (Karma, Jest, Vitest, NUnit
4
- * and most CI reporters). Both are parsed with regexes over tags and
5
- * attributes, which is enough for these machine-written formats.
3
+ * TRX (`dotnet test --logger trx`), JUnit XML (Karma, Jest, Vitest, NUnit
4
+ * and most CI reporters) and Jest JSON (`jest --json`, Vitest's `json`
5
+ * reporter). They are parsed with regexes over tags and attributes, or
6
+ * JSON.parse, which is enough for these machine-written formats.
6
7
  */
7
8
  import fs from "node:fs";
8
9
  import path from "node:path";
9
- import fg from "fast-glob";
10
+ import { globAbsolute } from "../scan.js";
10
11
  function attributes(tag) {
11
12
  const out = {};
12
13
  for (const m of tag.matchAll(/([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g))
@@ -142,19 +143,64 @@ export function parseJUnit(xml) {
142
143
  }
143
144
  return results;
144
145
  }
146
+ /**
147
+ * Jest's `--json` output (Vitest's `json` reporter writes the same shape): a
148
+ * test is named by its `describe` titles and its own, joined like JUnit names;
149
+ * a test outside any `describe` by its file name. Undefined when the text isn't that.
150
+ */
151
+ export function parseJestJson(text) {
152
+ let json;
153
+ try {
154
+ json = JSON.parse(text);
155
+ }
156
+ catch {
157
+ return undefined;
158
+ }
159
+ if (!Array.isArray(json?.testResults))
160
+ return undefined;
161
+ const results = empty();
162
+ for (const file of json.testResults) {
163
+ if (!file || typeof file !== "object" || !Array.isArray(file.assertionResults ?? []))
164
+ return undefined;
165
+ for (const test of file.assertionResults ?? []) {
166
+ if (!test || typeof test !== "object")
167
+ return undefined;
168
+ const owners = test.ancestorTitles?.length ? test.ancestorTitles : file.name ? [path.basename(file.name)] : [];
169
+ const status = test.status ?? "";
170
+ record(results, [...owners, test.title ?? ""].join(JUNIT_SEPARATOR), status === "passed" ? "passed" : status === "failed" ? "failed" : "skipped");
171
+ }
172
+ }
173
+ return results;
174
+ }
175
+ /**
176
+ * One result file by its content: TRX, Jest JSON or JUnit XML. Undefined when
177
+ * it can't be read as any, or the XML document isn't complete (still being written).
178
+ */
179
+ function parseResultFile(text) {
180
+ if (/^\s*\{/.test(text))
181
+ return parseJestJson(text);
182
+ if (/<TestRun\b/.test(text))
183
+ return /<\/TestRun>\s*$/.test(text) ? parseTrx(text) : undefined;
184
+ return /<testsuites?\b/.test(text) && /(?:<\/testsuites?>|<testsuites?\b[^>]*\/>)\s*$/.test(text)
185
+ ? parseJUnit(text)
186
+ : undefined;
187
+ }
188
+ /** Whether results name every test that ran (read from result files), not only the failing ones a log names. */
189
+ export function hasTestNames(results) {
190
+ return results !== undefined && results.source !== "log";
191
+ }
145
192
  /**
146
193
  * Merge every result file matching `pattern` (an absolute path or glob)
147
194
  * modified at or after `since` — test runners keep older result files around.
148
195
  * Files in `claimed` that haven't changed since are skipped: another
149
196
  * project's run just wrote them, and an overlapping pattern
150
197
  * (`./**\/TestResults/*.trx`) would count them twice. The files read are
151
- * added to `claimed`. Undefined when no such file exists.
198
+ * added to `claimed`. Undefined when no such file exists or none can be read.
152
199
  */
153
200
  export function collectTestResults(pattern, since, claimed = new Map()) {
154
201
  // Filesystem timestamps can be coarser than Date.now().
155
202
  const cutoff = since - 2000;
156
- const files = fg
157
- .sync(path.sep === "\\" ? pattern.replace(/\\/g, "/") : pattern, { absolute: true, dot: true })
203
+ const files = globAbsolute(pattern)
158
204
  .map((file) => ({ file, mtime: fs.statSync(file).mtimeMs }))
159
205
  .filter(({ file, mtime }) => mtime >= cutoff && claimed.get(file) !== mtime)
160
206
  .sort((a, b) => a.file.localeCompare(b.file));
@@ -164,8 +210,10 @@ export function collectTestResults(pattern, since, claimed = new Map()) {
164
210
  return undefined;
165
211
  const merged = empty();
166
212
  for (const { file } of files) {
167
- const xml = fs.readFileSync(file, "utf8");
168
- const parsed = /<TestRun\b/.test(xml) ? parseTrx(xml) : parseJUnit(xml);
213
+ // A file that is still being written, or isn't a result file, is skipped rather than failing the run.
214
+ const parsed = parseResultFile(fs.readFileSync(file, "utf8"));
215
+ if (!parsed)
216
+ continue;
169
217
  merged.total += parsed.total;
170
218
  merged.passed += parsed.passed;
171
219
  merged.failed += parsed.failed;
@@ -174,5 +222,5 @@ export function collectTestResults(pattern, since, claimed = new Map()) {
174
222
  merged.failedNames.push(...parsed.failedNames);
175
223
  merged.files.push(file);
176
224
  }
177
- return merged;
225
+ return merged.files.length ? merged : undefined;
178
226
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flagrm",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Guardrails for removing feature flags with AI coding agents (Claude Code, Copilot, Codex): skills, a baseline, a verify gate and a Stop hook.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -53,17 +53,17 @@
53
53
  ],
54
54
  "dependencies": {
55
55
  "commander": "^13.1.0",
56
- "fast-glob": "^3.3.3",
57
56
  "picocolors": "^1.1.1",
58
57
  "picomatch": "^4.0.7",
58
+ "tinyglobby": "^0.2.17",
59
59
  "typescript": "^5.7.0",
60
60
  "yaml": "^2.7.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@biomejs/biome": "2.5.10",
63
+ "@biomejs/biome": "2.5.15",
64
64
  "@types/node": "^22.13.0",
65
65
  "@types/picomatch": "^4.0.3",
66
66
  "tsx": "^4.19.0",
67
- "vitest": "^3.0.0"
67
+ "vitest": "^4.1.11"
68
68
  }
69
69
  }
@@ -16,6 +16,8 @@ You find and edit the code. `flagrm` records the state before you start and
16
16
  decides when you are done: **done means `flagrm verify <flag> --json` exited 0.**
17
17
  Run it as `npx --no-install flagrm`, one command per call, no `||` (plain `npx flagrm` is another package).
18
18
 
19
+ Several flags (`/flagrm-remove A B C`): read [batch.md](batch.md) first; it runs this workflow once per flag.
20
+
19
21
  ## 1. Preconditions
20
22
 
21
23
  - `flagrm doctor` must not warn about uncommitted files (flagrm's own setup files and `exclude`d paths don't count). Otherwise ask the user to commit or stash.
@@ -35,7 +37,7 @@ Without `failure`: a build, stop. Tests: stop if the failures (`results.failedTe
35
37
  ## 3. Find every usage
36
38
 
37
39
  - Grep for each name in `names[]`: code, templates, config and tests. `parameterizedTests[]` are test calls passing ON/OFF.
38
- - `suggestedNames[]`: values of the flag with generic names. Record each that carries only this flag, a local, parameter or field with `--file <its file>`.
40
+ - `suggestedNames[]`: values of the flag with generic names. Record each that carries only this flag, a local, parameter or field with `--file <its file>` (repeat `--file` for each file; a component's template is added for you).
39
41
  - Follow the value further: a method or property returning it, a field or local holding it, a parameter
40
42
  it's passed as, a DI-registered bool. Record each so `verify` checks it, then grep for it:
41
43
  `flagrm baseline <flag> --name IsNewCheckoutEnabledAsync --kind wrapper` (`--kind alias` for a constant like `Flags.NewCheckout`).
@@ -70,19 +72,37 @@ Read `patterns.md` in this skill's directory when a case is unclear.
70
72
  | `dead-code` | Delete what the removal left unused. A declaration "referenced only where it is declared" (warning): delete it if only the OFF path used it; else say why it stays |
71
73
  | `build` | Fix the error your edit caused; don't touch unrelated code. `tsc` skips templates: grep `.html` for each member you removed or renamed |
72
74
  | `tests` | A new failure means ON behavior changed: fix the edit, not the test. Known failures (failed at the baseline) only warn. A test listed as no longer running (`unexplained`) must be one you removed on purpose: say why. A changed test file whose tests didn't run: run them yourself and report it |
75
+ | `lint` | Fix what your edit caused. An already-failing lint fails only on new error lines: fix those; other new lines it lists only warn |
76
+ | `coverage` | Tell the user which changed files no command checks; don't add or change commands yourself |
77
+
78
+ Exit 2 is a usage or config error: read the message. `--skip build,tests,lint,coverage` is fine for a quick check in between; only a full run counts, and the Stop hook won't let you finish on one with `--skip`. After three failed attempts on one check, show the user the findings. Don't run code generators or formatters after the passing verify; if you must, verify again.
79
+
80
+ ## 6. Hand back
81
+
82
+ 1. `flagrm verify <flag> --md` on the final tree (it reuses the passing build, test and lint runs; edit nothing after it): show its output exactly as printed; don't reword it or change its numbers. It must pass: the Stop hook checks it.
83
+ 2. **Notes**, 3–5 lines: what you kept on purpose, each remaining warning with its reason, follow-ups in the flag service. If `state` wasn't `on` and nobody confirmed ON, start with "Assumed ON everywhere".
84
+ 3. The commit message:
85
+
86
+ <!-- commit-message:start -->
87
+ **Proposed commit message**, in a code block:
88
+
89
+ ```
90
+ chore: remove <flag> feature flag
73
91
 
74
- Exit 2 is a usage or config error: read the message. `--skip build,tests` is fine for a quick check
75
- in between; only a full run counts, and the Stop hook won't let you finish on one with `--skip`. After three failed attempts on one check, show the user the findings.
92
+ <one or two sentences: which path stays and what was deleted>
93
+ ```
76
94
 
77
- ## 6. Report
95
+ - Follow the repository's convention instead when `git log --oneline -10` shows a different one.
96
+ - A ticket in the branch name (`feature/feat-24586`): add it the way the log does (`#24586`, `FEAT-24586`), else `(#24586)` after the subject.
97
+ - Wrap the body at 72 columns.
98
+ <!-- commit-message:end -->
78
99
 
79
- In 3–5 lines: what was removed, what you kept on purpose, and each remaining warning with its
80
- reason. If `state` wasn't `on` and nobody confirmed ON, start with "Assumed ON everywhere".
81
- Suggest `/flagrm-verify <flag>` for the overview and commit message. Don't commit.
100
+ Commit only when the user asked (in a batch: their answer at the start).
82
101
 
83
102
  ## Rules
84
103
 
85
104
  - Never lift code out of the OFF path into live code.
86
105
  - Match the file's formatting; don't refactor unrelated code.
87
106
  - Never edit `.flagrm/`. Rerun `flagrm baseline <flag>` only with `--name`, or with `--force` before your first edit (it refuses after).
88
- - Never `--skip` a failing check, and never change a test's expected value or the build/test commands to pass. If the user changes the config mid-removal (say, to leave out tests that need services), run `flagrm baseline <flag> --accept config` once they confirm.
107
+ - Never `--skip` a failing check, and never change a test's expected value or the build, test or lint commands or `covers` to pass. If the user changes the config mid-removal (say, to leave out tests that need services), run `flagrm baseline <flag> --accept config` once they confirm.
108
+ - `baseline` and `verify` reuse a passing verify's build, test and lint results on the same tree and commands. If the user changed the machine (SDK, Docker, services) since, add `--no-reuse`.
@@ -0,0 +1,38 @@
1
+ # Remove several flags
2
+
3
+ Run SKILL.md's workflow once per flag, in the order given, one commit per flag.
4
+ Each baseline reuses the previous flag's passing verify (seconds) only if the commit leaves the tree as verified.
5
+
6
+ ## Once, before the first flag
7
+
8
+ 1. SKILL.md step 1 preconditions. Find every flag in `flagrm list --json`; for one that's missing, show the closest names and ask.
9
+ 2. One table: flag, `state`, and for any not `on` the environments in `config[]` that change.
10
+ Ask once: "Remove all of these as ON everywhere?" On a no, drop the flags the user names.
11
+ 3. Ask once: "Commit each flag once its verify passes?"
12
+
13
+ ## Per flag
14
+
15
+ Copy this checklist for each flag and tick it as you go:
16
+
17
+ ```
18
+ <flag>:
19
+ - [ ] baseline (step 2)
20
+ - [ ] every usage found (step 3)
21
+ - [ ] removed (step 4)
22
+ - [ ] verify passes (step 5)
23
+ - [ ] handed back (step 6)
24
+ - [ ] committed
25
+ ```
26
+
27
+ - The ON answer above is step 1's state answer: don't ask again.
28
+ - Commit answer yes: commit with the proposed message, then start the next flag. No: show the message,
29
+ stop, and continue once the user says it's committed. The next baseline needs the previous removal committed.
30
+ - A flag that needs the user (three failed attempts on one check, an unclear case) pauses the batch.
31
+ Never start a flag while the previous one is uncommitted.
32
+ - Optional: if you can start subagents, run each flag in its own, one at a time. A subagent sees no
33
+ conversation history: give it the flag, that it is confirmed ON everywhere, the commit answer, and
34
+ "follow flagrm-remove's SKILL.md steps 2–6".
35
+
36
+ ## At the end
37
+
38
+ One table: flag, commit, remaining warnings, kept on purpose.
@@ -35,14 +35,20 @@ Output, in this order:
35
35
  - follow-ups outside the repo: archive the flag in the flag service
36
36
  (LaunchDarkly, Azure App Configuration, ...), remove keys from deployment
37
37
  settings and secret stores.
38
- 3. **Proposed commit message**, in a code block:
38
+ 3. The commit message:
39
39
 
40
- ```
41
- chore: remove <flag> feature flag
40
+ <!-- commit-message:start -->
41
+ **Proposed commit message**, in a code block:
42
42
 
43
- <one or two sentences: which path stays and what was deleted>
44
- ```
43
+ ```
44
+ chore: remove <flag> feature flag
45
45
 
46
- Follow the repository's convention instead when `git log --oneline -10` shows a different one.
46
+ <one or two sentences: which path stays and what was deleted>
47
+ ```
48
+
49
+ - Follow the repository's convention instead when `git log --oneline -10` shows a different one.
50
+ - A ticket in the branch name (`feature/feat-24586`): add it the way the log does (`#24586`, `FEAT-24586`), else `(#24586)` after the subject.
51
+ - Wrap the body at 72 columns.
52
+ <!-- commit-message:end -->
47
53
 
48
54
  Never commit, push or open a PR unless asked. Write a PR description only on request, from the same output.