@erclx/canon 4.74.0 → 4.75.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.
@@ -20,6 +20,11 @@ import {
20
20
  treeRoots,
21
21
  } from '@/pr/bijection'
22
22
  import { type CheckRunListing, collapseChecks } from '@/pr/checks'
23
+ import {
24
+ findEvidenceCommentId,
25
+ groupEvidence,
26
+ renderEvidenceBody,
27
+ } from '@/pr/evidence'
23
28
  import { type HeadRefusal, resolveHead, resolveTip } from '@/pr/head'
24
29
  import { KEY_CHANGES } from '@/pr/paths'
25
30
  import { type ReviewListing, resolveReviewScope } from '@/pr/review-scope'
@@ -81,6 +86,27 @@ const PULL_REFUSALS: Record<PullRefusal | HeadRefusal, string> = {
81
86
  'The reviews on this pull request could not be read. An empty answer here would report a reviewed pull request as never reviewed, which routes the next pass to the whole change, so nothing is reported.',
82
87
  }
83
88
 
89
+ /**
90
+ * Why `canon pr evidence` produced no comment body, past the ones
91
+ * `readIdentity` already owns (`gh-missing`, `gh-failed`, `no-branch`) and the
92
+ * one `identity.head` owns (`no-object-head`).
93
+ */
94
+ type EvidenceRefusal =
95
+ | 'gh-failed'
96
+ | 'no-base'
97
+ | 'unreadable-tree'
98
+ | 'unreadable-changes'
99
+
100
+ const EVIDENCE_REFUSALS: Record<EvidenceRefusal, string> = {
101
+ 'gh-failed':
102
+ 'gh could not answer for this repository. Name the pull request number.',
103
+ 'no-base': 'No base resolves against the trunk. Fetch origin and re-run.',
104
+ 'unreadable-tree':
105
+ 'git could not read the tree at the base commit, so no path could be judged added or changed.',
106
+ 'unreadable-changes':
107
+ 'git could not list what this branch changed, so the set is unknown.',
108
+ }
109
+
84
110
  /** Why the read produced no comparison, ahead of the ones the compare owns. */
85
111
  type SourceRefusal =
86
112
  | 'gh-missing'
@@ -289,6 +315,46 @@ export function register(program: Command): void {
289
315
  .action(async (number: string | undefined, opts: ReadOptions) => {
290
316
  process.exitCode = await runReviewState(number, opts)
291
317
  })
318
+
319
+ pr.command('evidence')
320
+ .description(
321
+ "Render a before/after comparison for the pull request's changed evidence images",
322
+ )
323
+ .argument('[number]', 'Pull request to read, defaulting to this branch')
324
+ .helpOption('-h, --help', 'Show this help message')
325
+ .option('--root <path>', 'Repository to read, defaulting to the cwd')
326
+ .option('--json', 'Add a machine-readable record on stdout')
327
+ .addHelpText(
328
+ 'after',
329
+ [
330
+ '',
331
+ 'Compares the merge base with the trunk against the current head, never',
332
+ 'the previous push against the new one, so the comment never claims more',
333
+ 'than the branch currently shows. A path counts as evidence when one of',
334
+ 'its segments is literally `evidence` and the filename carries an image',
335
+ 'extension (png, jpg, jpeg, gif, webp, avif, svg). A README, a capture',
336
+ 'script, or a raw data file kept beside the images is left out rather',
337
+ 'than rendered as a broken embed.',
338
+ '',
339
+ 'Read `reason` on the JSON record before posting anything:',
340
+ ' ok a body was rendered, with `commentId` set when a marked',
341
+ ' comment already exists and should be edited in place',
342
+ ' no-evidence nothing in the diff carries an evidence/ segment, which',
343
+ ' is an ordinary, silent no-op rather than a refusal',
344
+ '',
345
+ 'Exit codes:',
346
+ ' 0 read, whether it produced a body or reported no-evidence',
347
+ ' 1 refused, with the reason on stderr or in the JSON record',
348
+ '',
349
+ 'Examples:',
350
+ ' canon pr evidence --json',
351
+ ' canon pr evidence 1341 --json',
352
+ '',
353
+ ].join('\n'),
354
+ )
355
+ .action(async (number: string | undefined, opts: ReadOptions) => {
356
+ process.exitCode = await runEvidence(number, opts)
357
+ })
292
358
  }
293
359
 
294
360
  interface PullRequestRead {
@@ -1019,6 +1085,160 @@ async function runReviewState(
1019
1085
  return 0
1020
1086
  }
1021
1087
 
1088
+ /**
1089
+ * Every path `git ls-tree` reports for `ref`, or undefined when the read
1090
+ * failed. One call for the whole tree rather than one `cat-file -e` per
1091
+ * evidence path, since existence at base is checked once per changed path and
1092
+ * a batch read is one round trip instead of many.
1093
+ */
1094
+ async function listTreePaths(
1095
+ root: string,
1096
+ ref: string,
1097
+ ): Promise<Set<string> | undefined> {
1098
+ const result = await $`git -C ${root} ls-tree -r --name-only ${ref}`
1099
+ .env(gitEnv())
1100
+ .quiet()
1101
+ .nothrow()
1102
+ if (result.exitCode !== 0) return undefined
1103
+ return new Set(result.text().split('\n').filter(Boolean))
1104
+ }
1105
+
1106
+ async function runEvidence(
1107
+ number: string | undefined,
1108
+ opts: ReadOptions,
1109
+ ): Promise<number> {
1110
+ const root = resolve(opts.root ?? process.cwd())
1111
+ const emitJson = opts.json ?? false
1112
+
1113
+ intro('canon pr evidence')
1114
+
1115
+ const read = await readIdentity(root, number)
1116
+ if (read.kind === 'refused') {
1117
+ return refuseWith(read.reason, PULL_REFUSALS[read.reason], emitJson, root)
1118
+ }
1119
+
1120
+ const { identity } = read
1121
+ if (identity.head === undefined) {
1122
+ return refuseWith(
1123
+ 'no-object-head',
1124
+ PULL_REFUSALS['no-object-head'],
1125
+ emitJson,
1126
+ root,
1127
+ )
1128
+ }
1129
+
1130
+ const base = await resolveBaseRef(root)
1131
+ if (base === undefined) {
1132
+ return refuseWith('no-base', EVIDENCE_REFUSALS['no-base'], emitJson, root)
1133
+ }
1134
+
1135
+ const [changed, baseTree] = await Promise.all([
1136
+ listChangedFiles(root, base),
1137
+ listTreePaths(root, base),
1138
+ ])
1139
+ if (changed === undefined) {
1140
+ return refuseWith(
1141
+ 'unreadable-changes',
1142
+ EVIDENCE_REFUSALS['unreadable-changes'],
1143
+ emitJson,
1144
+ root,
1145
+ )
1146
+ }
1147
+ if (baseTree === undefined) {
1148
+ return refuseWith(
1149
+ 'unreadable-tree',
1150
+ EVIDENCE_REFUSALS['unreadable-tree'],
1151
+ emitJson,
1152
+ root,
1153
+ )
1154
+ }
1155
+
1156
+ const grouped = await groupEvidence(changed, async (path) =>
1157
+ baseTree.has(path),
1158
+ )
1159
+
1160
+ if (grouped.kind === 'refused') {
1161
+ logStep('Skipped')
1162
+ logInfo(
1163
+ 'No changed path carries an evidence/ segment, so there is nothing to post.',
1164
+ )
1165
+ outro()
1166
+ if (emitJson) {
1167
+ process.stdout.write(
1168
+ `${JSON.stringify({
1169
+ root,
1170
+ ...(identity.number !== undefined && { number: identity.number }),
1171
+ reason: 'no-evidence',
1172
+ })}\n`,
1173
+ )
1174
+ }
1175
+ return 0
1176
+ }
1177
+
1178
+ const repoRow = await gh(root, ['repo', 'view', '--json', 'nameWithOwner'])
1179
+ const repo =
1180
+ repoRow === null
1181
+ ? undefined
1182
+ : (JSON.parse(repoRow) as { nameWithOwner?: string }).nameWithOwner
1183
+
1184
+ if (repo === undefined) {
1185
+ return refuseWith(
1186
+ 'gh-failed',
1187
+ EVIDENCE_REFUSALS['gh-failed'],
1188
+ emitJson,
1189
+ root,
1190
+ )
1191
+ }
1192
+
1193
+ const body = renderEvidenceBody(grouped.states, repo, base, identity.head)
1194
+
1195
+ let commentId: number | undefined
1196
+ if (identity.number !== undefined) {
1197
+ const commentsRow = await gh(root, [
1198
+ 'pr',
1199
+ 'view',
1200
+ String(identity.number),
1201
+ '--json',
1202
+ 'comments',
1203
+ ])
1204
+ if (commentsRow !== null) {
1205
+ try {
1206
+ const parsed = JSON.parse(commentsRow) as {
1207
+ comments?: readonly { url?: string; body: string }[]
1208
+ }
1209
+ commentId = findEvidenceCommentId(parsed.comments ?? [])
1210
+ } catch {
1211
+ commentId = undefined
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ const caseCount = grouped.states.reduce((n, s) => n + s.items.length, 0)
1217
+
1218
+ logStep('Scope')
1219
+ logInfo(
1220
+ `${plural(caseCount, 'case')} across ${plural(grouped.states.length, 'state')}`,
1221
+ )
1222
+
1223
+ outro()
1224
+
1225
+ if (emitJson) {
1226
+ process.stdout.write(
1227
+ `${JSON.stringify({
1228
+ root,
1229
+ ...(identity.number !== undefined && { number: identity.number }),
1230
+ reason: 'ok',
1231
+ base,
1232
+ head: identity.head,
1233
+ body,
1234
+ ...(commentId !== undefined && { commentId }),
1235
+ })}\n`,
1236
+ )
1237
+ }
1238
+
1239
+ return 0
1240
+ }
1241
+
1022
1242
  /**
1023
1243
  * Frames a refusal on stderr in both modes and puts the record on stdout alone,
1024
1244
  * so an operator reading the terminal sees the reason rather than a command
@@ -136,3 +136,62 @@
136
136
  *::-webkit-scrollbar-corner {
137
137
  background: transparent;
138
138
  }
139
+
140
+ /* hand-drawn-figure
141
+ The hand-drawn SVG diagram figure and its caption, sized wider than the
142
+ reading measure and cleared past an outline rail on a wide viewport. */
143
+
144
+ /* ---- Figures: hand-drawn, and wider than the measure ---- */
145
+
146
+ :root {
147
+ --figure-hand: 'Virgil', 'Excalifont', cursive;
148
+ --figure-wide: 64rem;
149
+ }
150
+
151
+ figure {
152
+ margin: 2.75rem 0;
153
+ width: var(--figure-wide);
154
+ max-width: 92vw;
155
+ margin-left: 50%;
156
+ transform: translateX(-50%);
157
+ }
158
+
159
+ figure svg {
160
+ width: 100%;
161
+ height: auto;
162
+ display: block;
163
+ }
164
+
165
+ /* CSS beats an SVG presentation attribute, so the diagrams pick up the
166
+ hand face without editing a single lesson. */
167
+ figure svg text {
168
+ font-family: var(--figure-hand);
169
+ }
170
+
171
+ figcaption {
172
+ font-family: var(--figure-hand);
173
+ font-size: 1rem;
174
+ color: var(--color-muted);
175
+ margin: 1rem auto 0;
176
+ line-height: 1.5;
177
+ max-width: 52rem;
178
+ }
179
+
180
+ /* A figure breaks the measure and must still clear an outline rail. Deriving
181
+ the ceiling from the viewport does not hold, since a rail positioned from
182
+ the centre keeps overlapping as the window widens, so the ceiling is fixed
183
+ instead. */
184
+ @media (min-width: 1421px) {
185
+ figure {
186
+ max-width: 54rem;
187
+ }
188
+ }
189
+
190
+ @media (max-width: 640px) {
191
+ figure {
192
+ width: 100%;
193
+ max-width: 100%;
194
+ margin-left: 0;
195
+ transform: none;
196
+ }
197
+ }
@@ -119,7 +119,86 @@ const SCROLLBAR: Component = {
119
119
  }`,
120
120
  }
121
121
 
122
- export const COMPONENTS: readonly Component[] = [STATUS, SCROLLBAR]
122
+ /**
123
+ * The hand-drawn SVG diagram figure and its caption, sized wider than the
124
+ * reading measure and cleared past an outline rail on a wide viewport.
125
+ * Promoted out of `TEACH_COMPONENTS` per
126
+ * `.canon/groundwork/86-portable-hand-drawn-figures/06-decision.md` item 4:
127
+ * any project can draw one hand-drawn-styled figure without teach's own quiz
128
+ * and schedule machinery. It could not move as `TEACH_FIGURES` as-is, since
129
+ * that component's `reads` list named `--teach-hand` and `--teach-wide`
130
+ * while only `TEACH_CHROME` declared them, so this component declares its
131
+ * own `--figure-hand` and `--figure-wide` defaults rather than borrowing
132
+ * undeclared custom properties from a teach-only sibling.
133
+ */
134
+ const HAND_DRAWN_FIGURE: Component = {
135
+ name: 'hand-drawn-figure',
136
+ note: [
137
+ 'The hand-drawn SVG diagram figure and its caption, sized wider than the',
138
+ 'reading measure and cleared past an outline rail on a wide viewport.',
139
+ ].join('\n '),
140
+ reads: ['--color-muted', '--figure-hand', '--figure-wide'],
141
+ rules: `/* ---- Figures: hand-drawn, and wider than the measure ---- */
142
+
143
+ :root {
144
+ --figure-hand: 'Virgil', 'Excalifont', cursive;
145
+ --figure-wide: 64rem;
146
+ }
147
+
148
+ figure {
149
+ margin: 2.75rem 0;
150
+ width: var(--figure-wide);
151
+ max-width: 92vw;
152
+ margin-left: 50%;
153
+ transform: translateX(-50%);
154
+ }
155
+
156
+ figure svg {
157
+ width: 100%;
158
+ height: auto;
159
+ display: block;
160
+ }
161
+
162
+ /* CSS beats an SVG presentation attribute, so the diagrams pick up the
163
+ hand face without editing a single lesson. */
164
+ figure svg text {
165
+ font-family: var(--figure-hand);
166
+ }
167
+
168
+ figcaption {
169
+ font-family: var(--figure-hand);
170
+ font-size: 1rem;
171
+ color: var(--color-muted);
172
+ margin: 1rem auto 0;
173
+ line-height: 1.5;
174
+ max-width: 52rem;
175
+ }
176
+
177
+ /* A figure breaks the measure and must still clear an outline rail. Deriving
178
+ the ceiling from the viewport does not hold, since a rail positioned from
179
+ the centre keeps overlapping as the window widens, so the ceiling is fixed
180
+ instead. */
181
+ @media (min-width: 1421px) {
182
+ figure {
183
+ max-width: 54rem;
184
+ }
185
+ }
186
+
187
+ @media (max-width: 640px) {
188
+ figure {
189
+ width: 100%;
190
+ max-width: 100%;
191
+ margin-left: 0;
192
+ transform: none;
193
+ }
194
+ }`,
195
+ }
196
+
197
+ export const COMPONENTS: readonly Component[] = [
198
+ STATUS,
199
+ SCROLLBAR,
200
+ HAND_DRAWN_FIGURE,
201
+ ]
123
202
 
124
203
  const TEACH_CHROME: Component = {
125
204
  name: 'teach-chrome',
@@ -153,7 +232,6 @@ const TEACH_CHROME: Component = {
153
232
  --teach-mono: 'Cascadia Code', ui-monospace, monospace;
154
233
  --teach-measure: 52rem;
155
234
  --teach-chrome: 52rem;
156
- --teach-wide: 64rem;
157
235
  --teach-mast-h: 4.4rem;
158
236
  --teach-shadow: 0 1px 2px rgba(20, 20, 20, 0.04);
159
237
  --color-teach-accent-bg: color-mix(in srgb, var(--color-accent) 14%, var(--color-background));
@@ -1126,53 +1204,6 @@ const TEACH_OUTLINE: Component = {
1126
1204
  @media (max-width: 1420px) { .outline { display: none; } }`,
1127
1205
  }
1128
1206
 
1129
- const TEACH_FIGURES: Component = {
1130
- name: 'teach-figures',
1131
- note: [
1132
- 'The hand-drawn SVG diagram figure and its caption, sized wider than the',
1133
- 'reading measure and cleared past the outline rail on a wide viewport.',
1134
- ].join('\n '),
1135
- reads: ['--color-muted', '--teach-hand', '--teach-measure', '--teach-wide'],
1136
- rules: `/* ---- Figures: hand-drawn, and wider than the measure ---- */
1137
-
1138
- figure {
1139
- margin: 2.75rem 0;
1140
- width: var(--teach-wide);
1141
- max-width: 92vw;
1142
- margin-left: 50%;
1143
- transform: translateX(-50%);
1144
- }
1145
-
1146
- figure svg { width: 100%; height: auto; display: block; }
1147
-
1148
- /* CSS beats an SVG presentation attribute, so the diagrams pick up the
1149
- hand face without editing a single lesson. */
1150
- figure svg text { font-family: var(--teach-hand); }
1151
-
1152
- figcaption {
1153
- font-family: var(--teach-hand);
1154
- font-size: 1rem;
1155
- color: var(--color-muted);
1156
- margin: 1rem auto 0;
1157
- line-height: 1.5;
1158
- max-width: var(--teach-measure);
1159
- }
1160
-
1161
- /* A figure breaks the measure and must still clear the outline rail. The rail
1162
- starts at \`50% + 26rem + 2.5rem\`, so a figure centred on the same axis may
1163
- reach 27rem from centre and no further, which is 54rem wide with a 1.5rem
1164
- gap left over. Deriving it from the viewport was the earlier attempt and it
1165
- does not hold: the rail is positioned from the centre, not from the edge, so
1166
- a wider window moved both and kept the overlap. */
1167
- @media (min-width: 1421px) {
1168
- figure { max-width: 54rem; }
1169
- }
1170
-
1171
- @media (max-width: 640px) {
1172
- figure { width: 100%; max-width: 100%; margin-left: 0; transform: none; }
1173
- }`,
1174
- }
1175
-
1176
1207
  const TEACH_REFERENCES: Component = {
1177
1208
  name: 'teach-references',
1178
1209
  note: [
@@ -1269,7 +1300,6 @@ export const TEACH_COMPONENTS: readonly Component[] = [
1269
1300
  TEACH_QUIZ,
1270
1301
  TEACH_GLOSSARY,
1271
1302
  TEACH_OUTLINE,
1272
- TEACH_FIGURES,
1273
1303
  TEACH_REFERENCES,
1274
1304
  ]
1275
1305