@trazum/cli 1.8.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/LICENSE +21 -0
- package/README.md +134 -0
- package/dist/git.d.ts +81 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +225 -0
- package/dist/git.js.map +1 -0
- package/dist/i18n/en.d.ts +10 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/en.js +694 -0
- package/dist/i18n/en.js.map +1 -0
- package/dist/i18n/es.d.ts +4 -0
- package/dist/i18n/es.d.ts.map +1 -0
- package/dist/i18n/es.js +694 -0
- package/dist/i18n/es.js.map +1 -0
- package/dist/i18n/index.d.ts +34 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +46 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/types.d.ts +363 -0
- package/dist/i18n/types.d.ts.map +1 -0
- package/dist/i18n/types.js +2 -0
- package/dist/i18n/types.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2824 -0
- package/dist/index.js.map +1 -0
- package/dist/markdown.d.ts +246 -0
- package/dist/markdown.d.ts.map +1 -0
- package/dist/markdown.js +492 -0
- package/dist/markdown.js.map +1 -0
- package/dist/suggest-cache.d.ts +119 -0
- package/dist/suggest-cache.d.ts.map +1 -0
- package/dist/suggest-cache.js +225 -0
- package/dist/suggest-cache.js.map +1 -0
- package/package.json +49 -0
- package/src/git.ts +294 -0
- package/src/i18n/en.ts +825 -0
- package/src/i18n/es.ts +838 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/types.ts +372 -0
- package/src/index.ts +3873 -0
- package/src/markdown.ts +717 -0
- package/src/suggest-cache.ts +268 -0
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
import type { CliMessages } from './i18n/index.js';
|
|
2
|
+
import type { Revision } from './git.js';
|
|
3
|
+
import type {
|
|
4
|
+
BaselineBreach,
|
|
5
|
+
BaselineComparison,
|
|
6
|
+
Locale,
|
|
7
|
+
PromptComparison,
|
|
8
|
+
PromptProfile,
|
|
9
|
+
RuleLevel,
|
|
10
|
+
} from '@trazum/core';
|
|
11
|
+
import { formatSignedUsd, formatUsd, getMessages, getModel } from '@trazum/core';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Markdown for the places a pull request is actually read.
|
|
15
|
+
*
|
|
16
|
+
* One renderer, two destinations. The GitHub step summary and a PR comment want
|
|
17
|
+
* the same numbers with different framing, and the numbers come from the same
|
|
18
|
+
* verdicts the terminal report prints — so a discrepancy between what a
|
|
19
|
+
* reviewer reads on the pull request and what the job log said is impossible by
|
|
20
|
+
* construction rather than by care.
|
|
21
|
+
*
|
|
22
|
+
* Nothing here knows the name `GITHUB_STEP_SUMMARY`. The CLI writes a file; the
|
|
23
|
+
* Action decides what that file is for. That keeps `trazum` a tool you can run
|
|
24
|
+
* on your laptop and read the output of.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** GitHub rejects a comment body over 65,536 characters. */
|
|
28
|
+
export const MAX_COMMENT_CHARS = 60_000;
|
|
29
|
+
|
|
30
|
+
/** A step summary is capped at 1 MiB. Well under it, and honest when it trims. */
|
|
31
|
+
export const MAX_SUMMARY_CHARS = 900_000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A value fit to sit in a table cell.
|
|
35
|
+
*
|
|
36
|
+
* Paths come from a repository, and on a pull request that means from whoever
|
|
37
|
+
* opened it. `prompts/a|b``c\|d.txt` is a legal POSIX filename, and each of
|
|
38
|
+
* those characters breaks a markdown table in its own way.
|
|
39
|
+
*
|
|
40
|
+
* **This emits `<code>` with HTML entities rather than a backtick span, and the
|
|
41
|
+
* reason is that the entity version has no failure mode to reason about.** The
|
|
42
|
+
* first version did the obvious thing — wrap in backticks, escape `|` as `\|`,
|
|
43
|
+
* widen the fence past the longest backtick run — and CodeQL was right to flag
|
|
44
|
+
* it: it did not handle a backslash. Given `a\|b.txt` it emitted `` `a\\|b.txt` ``,
|
|
45
|
+
* and whether that survives depends on whether the row splitter reads `\\|` as
|
|
46
|
+
* an escaped pipe or as an escaped backslash followed by a live one. It happens
|
|
47
|
+
* to work in cmark-gfm today. An escaper whose correctness rests on that is not
|
|
48
|
+
* an escaper.
|
|
49
|
+
*
|
|
50
|
+
* With entities there is **no `|` character in the output at all**, so the row
|
|
51
|
+
* cannot split under any scanner; backticks inside `<code>` are literal, so the
|
|
52
|
+
* fence arithmetic disappears; and a backslash needs no treatment. Three hazard
|
|
53
|
+
* classes collapse into one rule: encode `&`, `<`, `>` and `|`.
|
|
54
|
+
*
|
|
55
|
+
* Newlines still have to go — anything vertical ends the row — so they become a
|
|
56
|
+
* single space.
|
|
57
|
+
*/
|
|
58
|
+
export function mdCell(value: string): string {
|
|
59
|
+
const flat = value.replace(/[\r\n\t]+/g, ' ').trim();
|
|
60
|
+
if (flat === '') return '';
|
|
61
|
+
|
|
62
|
+
const encoded = flat
|
|
63
|
+
// `&` first, or it would double-encode the entities added below.
|
|
64
|
+
.replace(/&/g, '&')
|
|
65
|
+
.replace(/</g, '<')
|
|
66
|
+
.replace(/>/g, '>')
|
|
67
|
+
.replace(/\|/g, '|');
|
|
68
|
+
|
|
69
|
+
return `<code>${encoded}</code>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A value fit to sit inline in prose.
|
|
74
|
+
*
|
|
75
|
+
* For values that are *words* — a model's display name. Paths go through
|
|
76
|
+
* `mdCell`, because a path is code and a code span is both safer and less ugly:
|
|
77
|
+
* escaping every `.` and `-` turned `a.txt` into `a\.txt`, which renders
|
|
78
|
+
* correctly and reads like a bug to anyone who sees the source.
|
|
79
|
+
*
|
|
80
|
+
* So the escaped set is only what can change meaning **mid-line**: emphasis,
|
|
81
|
+
* code spans, links, autolinks and table cells. `#`, `-`, `+` and `.` are
|
|
82
|
+
* block-level constructs that need to start a line to mean anything, and the
|
|
83
|
+
* newline collapse above guarantees this value never does.
|
|
84
|
+
*/
|
|
85
|
+
export function mdText(value: string): string {
|
|
86
|
+
return value
|
|
87
|
+
.replace(/[\r\n]+/g, ' ')
|
|
88
|
+
.replace(/([\\`*_~[\]<>|])/g, '\\$1')
|
|
89
|
+
.trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Untrusted **prose** in a table cell.
|
|
94
|
+
*
|
|
95
|
+
* `mdCell` is for values that are code — a path, a sha — and it says so by
|
|
96
|
+
* wrapping them in `<code>`. A commit subject and an author's name are neither.
|
|
97
|
+
* Rendering `<code>David Muñoz Rey</code>` in a table typesets somebody's name as
|
|
98
|
+
* a code span, and `<code>fix: the rules only trimmed in two languages</code>`
|
|
99
|
+
* does the same to a sentence. Both were wrong in the first draft of the blame
|
|
100
|
+
* report, and only visible once it was rendered.
|
|
101
|
+
*
|
|
102
|
+
* The safety is `mdCell`'s, unchanged, for the same reason: **entities, so there
|
|
103
|
+
* is no `|` in the output at all** and the row cannot split under any scanner.
|
|
104
|
+
* `mdText`'s backslash escaping is complete and would also survive a cell, but it
|
|
105
|
+
* puts the correctness on a reader's ability to see that `\\\|` is an escaped
|
|
106
|
+
* backslash followed by an escaped pipe. Nothing here should need that.
|
|
107
|
+
*
|
|
108
|
+
* Then the inline-markdown set on top, which `mdCell` does not need because
|
|
109
|
+
* backticks make its content literal. A subject reading `fix *everything*` would
|
|
110
|
+
* otherwise arrive in italics, and two backticks in one would open a code span —
|
|
111
|
+
* cosmetic rather than dangerous, and still not what the author wrote.
|
|
112
|
+
*/
|
|
113
|
+
export function mdTextCell(value: string): string {
|
|
114
|
+
const flat = value.replace(/[\r\n\t]+/g, ' ').trim();
|
|
115
|
+
if (flat === '') return '';
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
flat
|
|
119
|
+
// `&` first, or it would double-encode the entities added below.
|
|
120
|
+
.replace(/&/g, '&')
|
|
121
|
+
.replace(/</g, '<')
|
|
122
|
+
.replace(/>/g, '>')
|
|
123
|
+
.replace(/\|/g, '|')
|
|
124
|
+
// Applied last, and deliberately not including `|`, `<` or `>`: those are
|
|
125
|
+
// already entities by this point and have no character left to escape.
|
|
126
|
+
.replace(/([\\`*_~[\]])/g, '\\$1')
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Truncates a body to fit, saying so rather than trailing off. */
|
|
131
|
+
export function fitWithin(body: string, limit: number, notice: string): string {
|
|
132
|
+
if (body.length <= limit) return body;
|
|
133
|
+
const room = limit - notice.length - 2;
|
|
134
|
+
return `${body.slice(0, Math.max(0, room))}\n\n${notice}`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export interface MarkdownFileVerdict {
|
|
138
|
+
path: string;
|
|
139
|
+
tokens: number;
|
|
140
|
+
/** null when no budget covers this file. */
|
|
141
|
+
maxTokens: number | null;
|
|
142
|
+
/** The config pattern the budget came from, if any. */
|
|
143
|
+
pattern: string | null;
|
|
144
|
+
/** Only set when the file is over budget. */
|
|
145
|
+
optimizedTokens: number | null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The cost diff, when the run had a baseline to compare against.
|
|
150
|
+
*
|
|
151
|
+
* Everything here is already computed by the time the report is rendered; this
|
|
152
|
+
* carries it rather than recomputing, so the comment on a pull request and the
|
|
153
|
+
* exit code can never disagree about whether the branch got more expensive.
|
|
154
|
+
*/
|
|
155
|
+
export interface BaselineMarkdown {
|
|
156
|
+
comparison: BaselineComparison;
|
|
157
|
+
breached: BaselineBreach[];
|
|
158
|
+
/** Recomputed monthly cost, and whether it is comparable to the baseline's. */
|
|
159
|
+
money: { before: number; after: number; comparable: boolean };
|
|
160
|
+
/** The file to re-record, named so the reader can act without looking it up. */
|
|
161
|
+
path: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface CheckMarkdownInput {
|
|
165
|
+
/** The directory or file the run was pointed at. */
|
|
166
|
+
target: string;
|
|
167
|
+
verdicts: MarkdownFileVerdict[];
|
|
168
|
+
level: RuleLevel;
|
|
169
|
+
tokenSource: 'heuristic' | 'external';
|
|
170
|
+
/** True when a walk limit stopped the run early. */
|
|
171
|
+
truncated: boolean;
|
|
172
|
+
/** Absent when no baseline governed the run. */
|
|
173
|
+
baseline?: BaselineMarkdown;
|
|
174
|
+
t: CliMessages;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const overBudget = (v: MarkdownFileVerdict): boolean =>
|
|
178
|
+
v.maxTokens !== null && v.tokens > v.maxTokens;
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The check report as markdown.
|
|
182
|
+
*
|
|
183
|
+
* The table is the whole point, so it comes first and the prose comes after. A
|
|
184
|
+
* reviewer scanning a comment reads the rows and stops.
|
|
185
|
+
*/
|
|
186
|
+
/**
|
|
187
|
+
* The baseline half of a check report.
|
|
188
|
+
*
|
|
189
|
+
* Only the directions that cost money are itemised — a list of every file that
|
|
190
|
+
* shrank buries the two rows a reviewer has to act on. Shrinking still gets its
|
|
191
|
+
* headline, because a branch that made things cheaper deserves to say so.
|
|
192
|
+
*/
|
|
193
|
+
function baselineBlock(baseline: BaselineMarkdown, t: CliMessages): string[] {
|
|
194
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
195
|
+
const md = t.markdown;
|
|
196
|
+
const { comparison, breached, money } = baseline;
|
|
197
|
+
const pct = (value: number): string => `${value > 0 ? '+' : ''}${value.toFixed(1)}%`;
|
|
198
|
+
const signed = (value: number): string => `${value > 0 ? '+' : ''}${n(value)}`;
|
|
199
|
+
|
|
200
|
+
const lines: string[] = [];
|
|
201
|
+
|
|
202
|
+
const headline =
|
|
203
|
+
comparison.delta === 0
|
|
204
|
+
? md.baselineUnchanged()
|
|
205
|
+
: comparison.delta > 0
|
|
206
|
+
? md.baselineGrew(n(comparison.delta), pct(comparison.deltaPct))
|
|
207
|
+
: md.baselineShrank(n(-comparison.delta), pct(comparison.deltaPct));
|
|
208
|
+
|
|
209
|
+
if (breached.length > 0) {
|
|
210
|
+
const limits = breached
|
|
211
|
+
.map((breach) =>
|
|
212
|
+
breach.kind === 'tokens'
|
|
213
|
+
? md.baselineLimitTokens(n(breach.limit))
|
|
214
|
+
: md.baselineLimitPct(String(breach.limit)),
|
|
215
|
+
)
|
|
216
|
+
.join(', ');
|
|
217
|
+
lines.push('> [!CAUTION]');
|
|
218
|
+
lines.push(`> **${headline}** — ${md.baselineOverLimit(limits)}.`);
|
|
219
|
+
} else {
|
|
220
|
+
lines.push(`**${headline}.**`);
|
|
221
|
+
}
|
|
222
|
+
lines.push('');
|
|
223
|
+
|
|
224
|
+
const moved = [...comparison.grown, ...comparison.added, ...comparison.removed];
|
|
225
|
+
if (moved.length > 0) {
|
|
226
|
+
lines.push(
|
|
227
|
+
`| | ${md.columnFile()} | ${md.baselineColumnBefore()} | ${md.baselineColumnAfter()} | ${md.columnChange()} |`,
|
|
228
|
+
);
|
|
229
|
+
lines.push('|:--:|---|--:|--:|--:|');
|
|
230
|
+
for (const change of comparison.grown) {
|
|
231
|
+
lines.push(
|
|
232
|
+
`| 📈 | ${mdCell(change.path)} | ${n(change.before)} | ${n(change.after)} | ${signed(change.delta)} |`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
for (const change of comparison.added) {
|
|
236
|
+
lines.push(
|
|
237
|
+
`| 🆕 | ${mdCell(change.path)} | – | ${n(change.after)} | ${signed(change.delta)} |`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
for (const change of comparison.removed) {
|
|
241
|
+
lines.push(
|
|
242
|
+
`| 🗑️ | ${mdCell(change.path)} | ${n(change.before)} | – | ${signed(change.delta)} |`,
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
lines.push('');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Money is shown when it means something and explained when it does not. A
|
|
249
|
+
// delta across a reprice is two different measurements subtracted, which is
|
|
250
|
+
// worse than no figure at all in a comment somebody will quote in a meeting.
|
|
251
|
+
lines.push(
|
|
252
|
+
money.comparable
|
|
253
|
+
? md.baselineMoney(
|
|
254
|
+
formatUsd(money.before),
|
|
255
|
+
formatUsd(money.after),
|
|
256
|
+
formatSignedUsd(money.after - money.before),
|
|
257
|
+
)
|
|
258
|
+
: `_${md.baselineMoneyIncomparable()}_`,
|
|
259
|
+
);
|
|
260
|
+
lines.push('');
|
|
261
|
+
|
|
262
|
+
if (breached.length > 0) {
|
|
263
|
+
lines.push(md.baselineReRecord('trazum baseline', baseline.path));
|
|
264
|
+
lines.push('');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return lines;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function renderCheckMarkdown(input: CheckMarkdownInput): string {
|
|
271
|
+
const { target, verdicts, level, tokenSource, truncated, t } = input;
|
|
272
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
273
|
+
const md = t.markdown;
|
|
274
|
+
|
|
275
|
+
const failures = verdicts.filter(overBudget);
|
|
276
|
+
const unbudgeted = verdicts.filter((v) => v.maxTokens === null);
|
|
277
|
+
// The verdict counts what was *measured*, not what was listed. "All 3 prompts
|
|
278
|
+
// are within budget" over a set where one had no budget claims something about
|
|
279
|
+
// that file which nobody established — and the unbudgeted note below is the
|
|
280
|
+
// honest half of the same sentence.
|
|
281
|
+
const measured = verdicts.length - unbudgeted.length;
|
|
282
|
+
|
|
283
|
+
const lines: string[] = [];
|
|
284
|
+
lines.push(`### ${md.checkHeading(mdCell(target))}`);
|
|
285
|
+
lines.push('');
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The cost diff leads.
|
|
289
|
+
*
|
|
290
|
+
* A reviewer reads the first two lines of a comment and scrolls past the rest.
|
|
291
|
+
* "Does each file fit its ceiling" is the older question and the narrower one;
|
|
292
|
+
* "did this branch make the repository more expensive" is what the pull request
|
|
293
|
+
* is actually proposing, so it goes above the table rather than under it.
|
|
294
|
+
*/
|
|
295
|
+
if (input.baseline) lines.push(...baselineBlock(input.baseline, t));
|
|
296
|
+
|
|
297
|
+
lines.push(
|
|
298
|
+
failures.length > 0
|
|
299
|
+
? `**${md.overBudget(failures.length, measured)}**`
|
|
300
|
+
: md.allWithin(measured),
|
|
301
|
+
);
|
|
302
|
+
lines.push('');
|
|
303
|
+
lines.push(`| | ${md.columnFile()} | ${md.columnTokens()} | ${md.columnBudget()} |`);
|
|
304
|
+
lines.push('|:--:|---|--:|--:|');
|
|
305
|
+
|
|
306
|
+
for (const v of verdicts) {
|
|
307
|
+
const mark = v.maxTokens === null ? '–' : overBudget(v) ? '❌' : '✅';
|
|
308
|
+
const budget = v.maxTokens === null ? md.noBudget() : n(v.maxTokens);
|
|
309
|
+
lines.push(`| ${mark} | ${mdCell(v.path)} | ${n(v.tokens)} | ${budget} |`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
lines.push('');
|
|
313
|
+
|
|
314
|
+
// Advice belongs under the table, once, rather than inside a cell where it
|
|
315
|
+
// would either be truncated or wreck the column widths.
|
|
316
|
+
const actionable = failures.filter((v) => v.optimizedTokens !== null);
|
|
317
|
+
if (actionable.length > 0) {
|
|
318
|
+
lines.push(`#### ${md.whatWouldHelp()}`);
|
|
319
|
+
lines.push('');
|
|
320
|
+
for (const v of actionable) {
|
|
321
|
+
const fits = v.optimizedTokens! <= v.maxTokens!;
|
|
322
|
+
lines.push(
|
|
323
|
+
`- ${mdCell(v.path)} — ${
|
|
324
|
+
fits
|
|
325
|
+
? md.wouldFit(level, n(v.optimizedTokens!))
|
|
326
|
+
: md.stillTooBig(n(v.optimizedTokens!))
|
|
327
|
+
}`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
lines.push('');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (unbudgeted.length > 0) {
|
|
334
|
+
// Named, not hidden. A prompt outside every pattern is not being watched,
|
|
335
|
+
// and a report that omits that reads as "everything is fine".
|
|
336
|
+
lines.push(md.unbudgetedNote(unbudgeted.length));
|
|
337
|
+
lines.push('');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (truncated) {
|
|
341
|
+
lines.push(`> [!WARNING]`);
|
|
342
|
+
lines.push(`> ${md.truncated()}`);
|
|
343
|
+
lines.push('');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
lines.push(
|
|
347
|
+
`<sub>${md.footer(
|
|
348
|
+
tokenSource === 'external' ? md.sourceExact() : md.sourceEstimated(),
|
|
349
|
+
level,
|
|
350
|
+
)}</sub>`,
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
return lines.join('\n');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export interface DiffMarkdownInput {
|
|
357
|
+
comparison: PromptComparison;
|
|
358
|
+
beforePath: string;
|
|
359
|
+
afterPath: string;
|
|
360
|
+
/** True when the figures came from the optimised text rather than as written. */
|
|
361
|
+
optimized: boolean;
|
|
362
|
+
locale: Locale;
|
|
363
|
+
t: CliMessages;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The diff report as markdown.
|
|
368
|
+
*
|
|
369
|
+
* Carries the sign convention into the heading, because this is the one place a
|
|
370
|
+
* reader arrives with no context: every number is `after - before`, and positive
|
|
371
|
+
* means worse. Getting that wrong in a PR comment would be worse than not
|
|
372
|
+
* commenting.
|
|
373
|
+
*/
|
|
374
|
+
export function renderDiffMarkdown(input: DiffMarkdownInput): string {
|
|
375
|
+
const { comparison, beforePath, afterPath, optimized, locale, t } = input;
|
|
376
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
377
|
+
const md = t.markdown;
|
|
378
|
+
const signed = (value: number): string => `${value > 0 ? '+' : ''}${n(value)}`;
|
|
379
|
+
|
|
380
|
+
const grew = comparison.tokenDelta > 0;
|
|
381
|
+
const mark = grew ? '⚠️' : comparison.tokenDelta < 0 ? '✅' : '➖';
|
|
382
|
+
|
|
383
|
+
const lines: string[] = [];
|
|
384
|
+
lines.push(`### ${md.diffHeading(mdCell(beforePath), mdCell(afterPath))}`);
|
|
385
|
+
lines.push('');
|
|
386
|
+
if (optimized) {
|
|
387
|
+
lines.push(`_${md.measuringOptimised()}_`);
|
|
388
|
+
lines.push('');
|
|
389
|
+
}
|
|
390
|
+
lines.push(`| | ${md.columnMetric()} | ${md.columnChange()} |`);
|
|
391
|
+
lines.push('|:--:|---|--:|');
|
|
392
|
+
lines.push(
|
|
393
|
+
`| ${mark} | ${md.metricTokens(n(comparison.tokensBefore), n(comparison.tokensAfter))} | ${signed(
|
|
394
|
+
comparison.tokenDelta,
|
|
395
|
+
)} (${signed(Math.round(comparison.deltaPct))}%) |`,
|
|
396
|
+
);
|
|
397
|
+
lines.push(
|
|
398
|
+
`| 💰 | ${mdText(
|
|
399
|
+
md.metricMonthly(
|
|
400
|
+
n(comparison.usage.callsPerMonth),
|
|
401
|
+
getModel(comparison.usage.model).displayName,
|
|
402
|
+
),
|
|
403
|
+
)} | ${formatSignedUsd(comparison.monthlyDeltaUsd)} |`,
|
|
404
|
+
);
|
|
405
|
+
lines.push('');
|
|
406
|
+
lines.push(`<sub>${md.deltaConvention()}</sub>`);
|
|
407
|
+
lines.push('');
|
|
408
|
+
|
|
409
|
+
const copy = getMessages(locale).rules;
|
|
410
|
+
const { rules, advisories } = comparison;
|
|
411
|
+
|
|
412
|
+
if (advisories.appeared.length > 0) {
|
|
413
|
+
lines.push(`> [!WARNING]`);
|
|
414
|
+
lines.push(`> **${md.advisoriesAppeared()}**`);
|
|
415
|
+
for (const id of advisories.appeared) lines.push(`> - \`${id}\``);
|
|
416
|
+
lines.push('');
|
|
417
|
+
}
|
|
418
|
+
if (advisories.resolved.length > 0) {
|
|
419
|
+
lines.push(`**${md.advisoriesResolved()}**`);
|
|
420
|
+
for (const id of advisories.resolved) lines.push(`- \`${id}\``);
|
|
421
|
+
lines.push('');
|
|
422
|
+
}
|
|
423
|
+
if (rules.newlyFiring.length > 0) {
|
|
424
|
+
lines.push(`**${md.rulesNewlyFiring()}**`);
|
|
425
|
+
for (const id of rules.newlyFiring) lines.push(`- ${mdText(copy[id].title)}`);
|
|
426
|
+
lines.push('');
|
|
427
|
+
}
|
|
428
|
+
if (rules.noLongerFiring.length > 0) {
|
|
429
|
+
lines.push(`**${md.rulesNoLongerFiring()}**`);
|
|
430
|
+
for (const id of rules.noLongerFiring) lines.push(`- ${mdText(copy[id].title)}`);
|
|
431
|
+
lines.push('');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return lines.join('\n').trimEnd();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Wraps a report for a pull request comment.
|
|
439
|
+
*
|
|
440
|
+
* **Collapsed when there is nothing wrong**, and that is the decision worth
|
|
441
|
+
* defending. A green table that stays green on every push is the thing a
|
|
442
|
+
* maintainer learns to skip — and once they skip it, they skip the red one too.
|
|
443
|
+
* Expanded means something needs reading.
|
|
444
|
+
*
|
|
445
|
+
* The marker is an HTML comment, invisible in the rendered comment and stable
|
|
446
|
+
* across pushes, so the poster can find its own previous comment and replace it
|
|
447
|
+
* rather than adding another. `key` separates two runs that legitimately post
|
|
448
|
+
* about different things in the same pull request.
|
|
449
|
+
*/
|
|
450
|
+
export function wrapForComment(
|
|
451
|
+
body: string,
|
|
452
|
+
options: { marker: string; ok: boolean; title: string; collapsedNote: string; trimNotice: string },
|
|
453
|
+
): string {
|
|
454
|
+
const { marker, ok, title, collapsedNote, trimNotice } = options;
|
|
455
|
+
const inner = fitWithin(body, MAX_COMMENT_CHARS - 400, trimNotice);
|
|
456
|
+
|
|
457
|
+
if (!ok) return `${marker}\n\n${inner}`;
|
|
458
|
+
|
|
459
|
+
return [
|
|
460
|
+
marker,
|
|
461
|
+
'',
|
|
462
|
+
`<details>`,
|
|
463
|
+
`<summary>✅ ${title} — ${collapsedNote}</summary>`,
|
|
464
|
+
'',
|
|
465
|
+
inner,
|
|
466
|
+
'',
|
|
467
|
+
`</details>`,
|
|
468
|
+
].join('\n');
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* The invisible anchor a comment is found by on the next push.
|
|
473
|
+
*
|
|
474
|
+
* The key reaches an HTML comment, so it is reduced to alphanumerics and single
|
|
475
|
+
* separators: runs collapse, edges are trimmed, and a key with nothing usable in
|
|
476
|
+
* it falls back to `default`. That leaves no `--` in the output at all, which
|
|
477
|
+
* takes the whole `-->` question off the table rather than reasoning about
|
|
478
|
+
* whether a particular arrangement of dashes happens to be safe.
|
|
479
|
+
*/
|
|
480
|
+
export function commentMarker(key: string): string {
|
|
481
|
+
const safe = key
|
|
482
|
+
.replace(/[^A-Za-z0-9]+/g, '-')
|
|
483
|
+
.replace(/^-+|-+$/g, '')
|
|
484
|
+
.slice(0, 64)
|
|
485
|
+
.replace(/-+$/, '');
|
|
486
|
+
const usable = /[A-Za-z0-9]/.test(safe) ? safe : 'default';
|
|
487
|
+
return `<!-- trazum-report:${usable} -->`;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export interface RankMarkdownRow {
|
|
491
|
+
path: string;
|
|
492
|
+
profile: PromptProfile;
|
|
493
|
+
/** Tokens the deterministic rules would take, at the level asked for. */
|
|
494
|
+
recoverable: number;
|
|
495
|
+
/** What those tokens cost per month under the usage profile. */
|
|
496
|
+
recoverableUsd: number;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export interface RankMarkdownInput {
|
|
500
|
+
/** The directory the run was pointed at. */
|
|
501
|
+
root: string;
|
|
502
|
+
ranked: readonly RankMarkdownRow[];
|
|
503
|
+
level: RuleLevel;
|
|
504
|
+
modelDisplayName: string;
|
|
505
|
+
callsPerMonth: number;
|
|
506
|
+
/** True when a walk limit stopped the run early. */
|
|
507
|
+
truncated: boolean;
|
|
508
|
+
/** Source files with no marker, skipped rather than aborting the run. */
|
|
509
|
+
skipped: number;
|
|
510
|
+
t: CliMessages;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The ranking as markdown.
|
|
515
|
+
*
|
|
516
|
+
* Every string but the heading comes from `t.rank`, the same object the terminal
|
|
517
|
+
* report reads. That is not tidiness — a second copy of "there is no score" is a
|
|
518
|
+
* second thing to keep true, and the first time somebody softens one of these
|
|
519
|
+
* sentences they will soften the copy they happened to be looking at.
|
|
520
|
+
*
|
|
521
|
+
* **Money and tokens stay in adjacent columns**, as in the terminal, and for the
|
|
522
|
+
* reason the terminal has them: four prompts reading `$0.25` looked like four
|
|
523
|
+
* equivalent jobs when three of them recovered a single token. A pull request
|
|
524
|
+
* comment is where that misreading would do the most damage, because nobody
|
|
525
|
+
* reading one has the file open.
|
|
526
|
+
*/
|
|
527
|
+
export function renderRankMarkdown(input: RankMarkdownInput): string {
|
|
528
|
+
const { root, ranked, level, modelDisplayName, callsPerMonth, truncated, skipped, t } = input;
|
|
529
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
530
|
+
const cols = t.rank.columns;
|
|
531
|
+
|
|
532
|
+
const lines: string[] = [];
|
|
533
|
+
lines.push(`### ${t.markdown.rankHeading(mdCell(root), ranked.length)}`);
|
|
534
|
+
lines.push('');
|
|
535
|
+
lines.push(t.rank.subheading(mdText(modelDisplayName), n(callsPerMonth)));
|
|
536
|
+
lines.push('');
|
|
537
|
+
lines.push(
|
|
538
|
+
`| ${cols.recoverable} | ${cols.tokensBack} | ${cols.tokens} | ${cols.density} | ${cols.notes} |`,
|
|
539
|
+
);
|
|
540
|
+
lines.push('|--:|--:|--:|--:|---|');
|
|
541
|
+
|
|
542
|
+
for (const entry of ranked) {
|
|
543
|
+
const { profile } = entry;
|
|
544
|
+
const notes: string[] = [];
|
|
545
|
+
if (profile.examples > 0) {
|
|
546
|
+
notes.push(t.rank.noteExamples(profile.examples, n(profile.exampleTokens)));
|
|
547
|
+
}
|
|
548
|
+
if (profile.formatTokens > 0) notes.push(t.rank.noteFormat(n(profile.formatTokens)));
|
|
549
|
+
const protectedShare = profile.tokens === 0 ? 0 : profile.protectedTokens / profile.tokens;
|
|
550
|
+
if (protectedShare >= 0.25) notes.push(t.rank.noteProtected(Math.round(protectedShare * 100)));
|
|
551
|
+
|
|
552
|
+
// The path is `<code>`; the notes are prose in the same cell. Two escapers
|
|
553
|
+
// in one cell because they are two kinds of value, and `mdCell` on a whole
|
|
554
|
+
// sentence would entity-encode punctuation nobody needs encoded.
|
|
555
|
+
const note = notes.length > 0 ? ` — ${mdTextCell(notes.join(', '))}` : '';
|
|
556
|
+
lines.push(
|
|
557
|
+
`| ${formatUsd(entry.recoverableUsd)} | ${n(entry.recoverable)} | ${n(profile.tokens)} | `
|
|
558
|
+
+ `${profile.tokensPerSentence.toFixed(1)} | ${mdCell(entry.path)}${note} |`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
lines.push('');
|
|
563
|
+
|
|
564
|
+
if (truncated) {
|
|
565
|
+
lines.push('> [!WARNING]');
|
|
566
|
+
lines.push(`> ${t.check.walkTruncated()}`);
|
|
567
|
+
lines.push('');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Named rather than silent, exactly as in the terminal: a repository where
|
|
571
|
+
// most prompts live in code would otherwise show a short list and read as the
|
|
572
|
+
// whole picture.
|
|
573
|
+
if (skipped > 0) {
|
|
574
|
+
lines.push(t.rank.skipped(skipped));
|
|
575
|
+
lines.push('');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
lines.push(`<sub>${mdText(t.rank.densityNote())}</sub>`);
|
|
579
|
+
lines.push('');
|
|
580
|
+
lines.push(`<sub>${mdText(t.rank.recoverableNote())} ${t.markdown.rankLevel(level)}</sub>`);
|
|
581
|
+
|
|
582
|
+
return lines.join('\n');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export interface BlameMarkdownRow {
|
|
586
|
+
revision: Revision;
|
|
587
|
+
/** `null` when the file did not exist at that commit, or held no marked prompt. */
|
|
588
|
+
tokens: number | null;
|
|
589
|
+
/** Tokens added since the previous (older) revision. `null` for the first. */
|
|
590
|
+
delta: number | null;
|
|
591
|
+
/** The name the file had at that commit, when it differs from today's. */
|
|
592
|
+
name: string | null;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export interface BlameMarkdownInput {
|
|
596
|
+
repoPath: string;
|
|
597
|
+
rows: readonly BlameMarkdownRow[];
|
|
598
|
+
truncated: boolean;
|
|
599
|
+
/** The priced movement across the history, when a model was resolved. */
|
|
600
|
+
netCost: { amount: string; modelDisplayName: string; callsPerMonth: number } | null;
|
|
601
|
+
t: CliMessages;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The token history as markdown.
|
|
606
|
+
*
|
|
607
|
+
* A rise is bold and a fall is not, which is the same asymmetry the terminal
|
|
608
|
+
* makes with colour: growth is the thing somebody has to act on, and a report
|
|
609
|
+
* that shouts equally about both trains the reader to ignore it.
|
|
610
|
+
*
|
|
611
|
+
* **Author and subject are the least trusted values this repository renders.**
|
|
612
|
+
* They come from commit metadata, which on a pull request from a fork is written
|
|
613
|
+
* by whoever opened it, and they land in a table on a page maintainers read. Both
|
|
614
|
+
* go through `mdCell`, which emits entities rather than escapes — so there is no
|
|
615
|
+
* `|` in the output to split a row and no backtick arithmetic to get wrong.
|
|
616
|
+
*/
|
|
617
|
+
export function renderBlameMarkdown(input: BlameMarkdownInput): string {
|
|
618
|
+
const { repoPath, rows, truncated, netCost, t } = input;
|
|
619
|
+
const n = (value: number): string => value.toLocaleString(t.numberLocale);
|
|
620
|
+
const cols = t.blame.columns;
|
|
621
|
+
|
|
622
|
+
const measured = rows.filter((r): r is BlameMarkdownRow & { tokens: number } => r.tokens !== null);
|
|
623
|
+
const newest = measured[0];
|
|
624
|
+
const oldest = measured[measured.length - 1];
|
|
625
|
+
|
|
626
|
+
const lines: string[] = [];
|
|
627
|
+
lines.push(`### ${t.markdown.blameHeading(mdCell(repoPath))}`);
|
|
628
|
+
lines.push('');
|
|
629
|
+
lines.push(`| ${cols.when} | ${cols.tokens} | ${cols.change} | ${cols.who} | ${cols.commit} |`);
|
|
630
|
+
lines.push('|---|--:|--:|---|---|');
|
|
631
|
+
|
|
632
|
+
for (const row of rows) {
|
|
633
|
+
const tokens = row.tokens === null ? t.blame.goneAt() : n(row.tokens);
|
|
634
|
+
const change =
|
|
635
|
+
row.delta === null
|
|
636
|
+
? row.tokens === null
|
|
637
|
+
? ''
|
|
638
|
+
: t.blame.addedAt()
|
|
639
|
+
: row.delta > 0
|
|
640
|
+
? `**+${n(row.delta)}**`
|
|
641
|
+
: row.delta < 0
|
|
642
|
+
? n(row.delta)
|
|
643
|
+
: '·';
|
|
644
|
+
|
|
645
|
+
lines.push(
|
|
646
|
+
`| ${row.revision.date.slice(0, 10)} | ${tokens} | ${change} | `
|
|
647
|
+
+ `${mdTextCell(row.revision.author)} | ${mdCell(row.revision.shortSha)} `
|
|
648
|
+
+ `${mdTextCell(row.revision.subject)} |`,
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
lines.push('');
|
|
653
|
+
|
|
654
|
+
if (truncated) {
|
|
655
|
+
lines.push(t.blame.truncated(rows.length));
|
|
656
|
+
lines.push('');
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const renamed = rows.find((row) => row.name !== null);
|
|
660
|
+
if (renamed?.name) {
|
|
661
|
+
lines.push(t.blame.followedRename(mdCell(renamed.name)));
|
|
662
|
+
lines.push('');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (newest && oldest && newest !== oldest) {
|
|
666
|
+
const delta = newest.tokens - oldest.tokens;
|
|
667
|
+
const pct =
|
|
668
|
+
oldest.tokens === 0
|
|
669
|
+
? '—'
|
|
670
|
+
: `${delta >= 0 ? '+' : ''}${((delta / oldest.tokens) * 100).toFixed(0)}%`;
|
|
671
|
+
lines.push(
|
|
672
|
+
`**${t.blame.net(
|
|
673
|
+
n(oldest.tokens),
|
|
674
|
+
n(newest.tokens),
|
|
675
|
+
`${delta >= 0 ? '+' : ''}${n(delta)}`,
|
|
676
|
+
pct,
|
|
677
|
+
)}**`,
|
|
678
|
+
);
|
|
679
|
+
lines.push('');
|
|
680
|
+
|
|
681
|
+
// Priced by the caller, which owns the usage profile. Recomputing it here
|
|
682
|
+
// would give a comment and a job log two chances to disagree about the same
|
|
683
|
+
// history.
|
|
684
|
+
if (netCost !== null) {
|
|
685
|
+
lines.push(
|
|
686
|
+
t.blame.netCost(
|
|
687
|
+
netCost.amount,
|
|
688
|
+
mdText(netCost.modelDisplayName),
|
|
689
|
+
n(netCost.callsPerMonth),
|
|
690
|
+
),
|
|
691
|
+
);
|
|
692
|
+
lines.push('');
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// The single worst commit, which is the question the command is really for.
|
|
697
|
+
const worst = rows
|
|
698
|
+
.filter((row): row is BlameMarkdownRow & { delta: number } => row.delta !== null && row.delta > 0)
|
|
699
|
+
.sort((a, b) => b.delta - a.delta)[0];
|
|
700
|
+
if (worst) {
|
|
701
|
+
lines.push(`#### ${t.blame.biggestRise()}`);
|
|
702
|
+
lines.push('');
|
|
703
|
+
lines.push(
|
|
704
|
+
`- ${t.blame.biggestRiseDetail(
|
|
705
|
+
n(worst.delta),
|
|
706
|
+
mdTextCell(worst.revision.author),
|
|
707
|
+
mdTextCell(worst.revision.subject),
|
|
708
|
+
mdCell(worst.revision.shortSha),
|
|
709
|
+
)}`,
|
|
710
|
+
);
|
|
711
|
+
lines.push('');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
lines.push(`<sub>${mdText(t.blame.estimateNote())}</sub>`);
|
|
715
|
+
|
|
716
|
+
return lines.join('\n');
|
|
717
|
+
}
|