@erclx/canon 4.9.2 → 4.11.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/canon-feedback-file/SKILL.md +41 -11
- package/docs/agents/capture.md +2 -2
- package/docs/agents/commands.md +6 -1
- package/docs/ai-workflow.md +1 -1
- package/docs/target-projects.md +11 -0
- package/package.json +1 -1
- package/scripts/core/install-check.sh +29 -17
- package/scripts/core/regen-hero.sh +69 -23
- package/src/commands/feedback-format.ts +74 -1
- package/src/commands/feedback.ts +23 -9
- package/src/commands/migrate.ts +173 -0
- package/src/design/base.css +3 -1
- package/src/design/tokens.ts +17 -3
- package/src/gate/measures.ts +50 -28
- package/src/gate/stages.ts +8 -4
- package/src/github-format.ts +62 -0
- package/src/github.ts +17 -6
- package/src/migrate/record-tree.ts +343 -0
- package/src/migrate/records.ts +6 -1
package/src/commands/migrate.ts
CHANGED
|
@@ -4,6 +4,13 @@ import type { Command } from 'commander'
|
|
|
4
4
|
import { listRepositoryFiles } from '@/git-files'
|
|
5
5
|
import { applyRecordsMove, applyRename, readSources } from '@/migrate/apply'
|
|
6
6
|
import { isToolkitOwned, planRename, type RenamePlan } from '@/migrate/plan'
|
|
7
|
+
import {
|
|
8
|
+
applyRecordTree,
|
|
9
|
+
planRecordTree,
|
|
10
|
+
readRecordTree,
|
|
11
|
+
type RecordTreePlan,
|
|
12
|
+
walkRecordTree,
|
|
13
|
+
} from '@/migrate/record-tree'
|
|
7
14
|
import {
|
|
8
15
|
ignoresDestination,
|
|
9
16
|
isRecordArtifact,
|
|
@@ -280,6 +287,125 @@ function toRecordsRecord(
|
|
|
280
287
|
}
|
|
281
288
|
}
|
|
282
289
|
|
|
290
|
+
interface RecordTreeOptions {
|
|
291
|
+
readonly json?: boolean
|
|
292
|
+
readonly write?: boolean
|
|
293
|
+
readonly root?: string
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Repoints the old-root citations left inside the record tree itself.
|
|
298
|
+
*
|
|
299
|
+
* The sibling verb sweeps a git listing, which reaches every tracked file and
|
|
300
|
+
* none of the records, since those are gitignored by construction. This one
|
|
301
|
+
* walks the new root directly and is scoped to the folders a session still
|
|
302
|
+
* follows a path into, so the closed trails and the scratch folder keep saying
|
|
303
|
+
* what they say.
|
|
304
|
+
*
|
|
305
|
+
* No ignore gate here. Nothing moves, so there is no destination whose ignore
|
|
306
|
+
* status could publish a record, and a run in a project the move has not
|
|
307
|
+
* reached finds no new root to walk and reports nothing.
|
|
308
|
+
*/
|
|
309
|
+
async function runRecordTree(opts: RecordTreeOptions): Promise<number> {
|
|
310
|
+
const root = opts.root ?? process.cwd()
|
|
311
|
+
|
|
312
|
+
const walk = await walkRecordTree(root)
|
|
313
|
+
const plan = planRecordTree(
|
|
314
|
+
await readRecordTree(root, walk.files),
|
|
315
|
+
walk.excluded,
|
|
316
|
+
walk.skipped,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
// stdout, so the record pipes clean. `pipeOutput` frames to stderr, which is
|
|
320
|
+
// where this command's report belongs and where a JSON record does not.
|
|
321
|
+
if (opts.json) {
|
|
322
|
+
process.stdout.write(
|
|
323
|
+
`${JSON.stringify(toRecordTreeRecord(plan, walk.files.length, opts.write))}\n`,
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
reportRecordTree(plan, walk.files.length)
|
|
328
|
+
|
|
329
|
+
if (plan.entries.length === 0) return 0
|
|
330
|
+
|
|
331
|
+
if (!opts.write) {
|
|
332
|
+
logWarn('Nothing was written. Pass --write to apply this plan.')
|
|
333
|
+
return 2
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const applied = await applyRecordTree(root, plan)
|
|
337
|
+
logStep(`Rewrote ${plural(applied.written, 'file')}.`)
|
|
338
|
+
|
|
339
|
+
if (applied.failed.length > 0) {
|
|
340
|
+
logError(`Could not write ${plural(applied.failed.length, 'file')}.`)
|
|
341
|
+
for (const path of applied.failed) logError(` ${path}`)
|
|
342
|
+
return 1
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return 0
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** How much of a citation's line the report prints before it wraps. */
|
|
349
|
+
const LINE_WIDTH = 160
|
|
350
|
+
|
|
351
|
+
function excerpt(text: string): string {
|
|
352
|
+
return text.length <= LINE_WIDTH ? text : `${text.slice(0, LINE_WIDTH)}…`
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Names every citation with the line it sits on.
|
|
357
|
+
*
|
|
358
|
+
* The record tree is untracked and unbacked, so a wrong rewrite has no git undo
|
|
359
|
+
* and a count would give a reader nothing to judge before passing `--write`.
|
|
360
|
+
* The excluded corpora take the opposite treatment for the same reason
|
|
361
|
+
* `reportRecords` counts the records it skips: they are thousands of files
|
|
362
|
+
* nobody is being asked to check.
|
|
363
|
+
*/
|
|
364
|
+
function reportRecordTree(plan: RecordTreePlan, swept: number): void {
|
|
365
|
+
logInfo(`${plural(swept, 'file')} in the live record surface.`)
|
|
366
|
+
logInfo(
|
|
367
|
+
`${plural(plan.entries.length, 'file')} to change, ${plural(plan.rewritten, 'citation')} to rewrite.`,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
for (const entry of plan.entries) {
|
|
371
|
+
for (const line of entry.lines) {
|
|
372
|
+
logInfo(` ${entry.path}:${line.line} ${excerpt(line.text)}`)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
logInfo(`${plural(plan.kept, 'citation')} marked to keep the old root.`)
|
|
377
|
+
|
|
378
|
+
for (const corpus of plan.excluded) {
|
|
379
|
+
logInfo(` ${corpus.path}: ${plural(corpus.files, 'file')}, left alone.`)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
for (const path of plan.skipped) {
|
|
383
|
+
logInfo(` ${path}: a git object store, skipped by name.`)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function toRecordTreeRecord(
|
|
388
|
+
plan: RecordTreePlan,
|
|
389
|
+
swept: number,
|
|
390
|
+
wrote: boolean | undefined,
|
|
391
|
+
): unknown {
|
|
392
|
+
return {
|
|
393
|
+
ok: true,
|
|
394
|
+
wrote: wrote === true,
|
|
395
|
+
swept,
|
|
396
|
+
files: plan.entries.length,
|
|
397
|
+
rewritten: plan.rewritten,
|
|
398
|
+
kept: plan.kept,
|
|
399
|
+
excluded: plan.excluded,
|
|
400
|
+
skipped: plan.skipped,
|
|
401
|
+
paths: plan.entries.map((entry) => ({
|
|
402
|
+
path: entry.path,
|
|
403
|
+
rewritten: entry.rewritten,
|
|
404
|
+
lines: entry.lines,
|
|
405
|
+
})),
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
283
409
|
export function register(program: Command): void {
|
|
284
410
|
const migrate = program
|
|
285
411
|
.command('migrate')
|
|
@@ -328,6 +454,53 @@ export function register(program: Command): void {
|
|
|
328
454
|
process.exitCode = await runRecords(opts)
|
|
329
455
|
})
|
|
330
456
|
|
|
457
|
+
migrate
|
|
458
|
+
.command('record-tree')
|
|
459
|
+
.description('Repoint old-root citations inside the records themselves')
|
|
460
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
461
|
+
.option('--json', 'Add a machine-readable record on stdout')
|
|
462
|
+
.option('--write', 'Apply the plan rather than reporting it')
|
|
463
|
+
.option(
|
|
464
|
+
'--root <path>',
|
|
465
|
+
'Project root, defaulting to the working directory',
|
|
466
|
+
)
|
|
467
|
+
.addHelpText(
|
|
468
|
+
'after',
|
|
469
|
+
[
|
|
470
|
+
'',
|
|
471
|
+
'Run this after canon migrate records. That verb sweeps a git listing,',
|
|
472
|
+
'so it reaches every tracked file and none of the records, which are',
|
|
473
|
+
'gitignored by construction. This one walks the new root itself.',
|
|
474
|
+
'',
|
|
475
|
+
'Scope: diagrams, memory, plans, proposals, review, tasks, and teach,',
|
|
476
|
+
'each minus its own archive subtree. The closed trails under groundwork',
|
|
477
|
+
'and intake, the scratch folder, and the backup history are reported as',
|
|
478
|
+
'counts and never rewritten, since a path inside a closed trail is part',
|
|
479
|
+
'of a sentence about work that already ended.',
|
|
480
|
+
'',
|
|
481
|
+
'Exit codes:',
|
|
482
|
+
' 0 nothing to rewrite, or --write applied the whole plan',
|
|
483
|
+
' 1 a write failed',
|
|
484
|
+
' 2 a plan exists and --write was not passed',
|
|
485
|
+
'',
|
|
486
|
+
'Every citation is reported with its file, its line, and the line text.',
|
|
487
|
+
'The records are untracked and unbacked, so a wrong rewrite has no undo',
|
|
488
|
+
'and the report is what a reader judges before passing --write.',
|
|
489
|
+
'',
|
|
490
|
+
'A line carrying canon-keep-record-root, or the line below it, keeps the',
|
|
491
|
+
'old root. Prose that dates a decision needs it; a live path does not.',
|
|
492
|
+
'',
|
|
493
|
+
'Examples:',
|
|
494
|
+
' canon migrate record-tree',
|
|
495
|
+
' canon migrate record-tree --write',
|
|
496
|
+
' canon migrate record-tree --json',
|
|
497
|
+
'',
|
|
498
|
+
].join('\n'),
|
|
499
|
+
)
|
|
500
|
+
.action(async (opts: RecordTreeOptions) => {
|
|
501
|
+
process.exitCode = await runRecordTree(opts)
|
|
502
|
+
})
|
|
503
|
+
|
|
331
504
|
migrate
|
|
332
505
|
.command('rename')
|
|
333
506
|
.description('Rewrite every unprotected aitk token to canon')
|
package/src/design/base.css
CHANGED
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
:root {
|
|
7
7
|
--color-background: #191512;
|
|
8
8
|
--color-surface: #211c19;
|
|
9
|
+
--color-chrome: #241e1a;
|
|
9
10
|
--color-border: #2f2823;
|
|
10
11
|
--color-text: #f4efe9;
|
|
11
12
|
--color-text-body: #c9c0b7;
|
|
12
13
|
--color-text-secondary: #a79d94;
|
|
13
14
|
--color-muted: #948a81;
|
|
14
15
|
--color-accent: #e0724b;
|
|
16
|
+
--color-success: #61c454;
|
|
15
17
|
--color-light-background: #faf7f2;
|
|
16
18
|
--color-light-surface: #f4efe6;
|
|
17
19
|
--color-light-text: #1a1815;
|
|
@@ -43,7 +45,7 @@
|
|
|
43
45
|
--radius-marker: 999px;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
/* The record declares no light counterpart for text-body, text-secondary, so
|
|
48
|
+
/* The record declares no light counterpart for chrome, text-body, text-secondary, success, so
|
|
47
49
|
a light-ground surface using one is reading a dark value. Declare the
|
|
48
50
|
counterpart in src/design/tokens.ts rather than overriding it here. */
|
|
49
51
|
[data-theme='light'] {
|
package/src/design/tokens.ts
CHANGED
|
@@ -80,7 +80,9 @@ export const TOKENS: DesignTokens = {
|
|
|
80
80
|
'',
|
|
81
81
|
'The values below are the system rather than a reading of one. Until 2026-09-01 this record transcribed two surfaces and agreed with nothing else, which is what made a change to it reach nobody. The slide theme, the token preview, and a teach workspace stylesheet now read the module this file is rendered from, so a value changed there changes what all three render.',
|
|
82
82
|
'',
|
|
83
|
-
'
|
|
83
|
+
'The two rendered captures read it as well. `scripts/core/regen-hero.sh` fills `assets/hero.html.tmpl` and `assets/install.html.tmpl` with what `canon design css --no-components` emits, so both frames now carry the custom properties rather than their own copies of the hex, and a value moved here moves what the next capture renders.',
|
|
84
|
+
'',
|
|
85
|
+
'The terminal framing is the one surface left holding its own values, and that is a decision rather than a gap. `scripts/lib/ui.sh` and `src/ui.ts` each spell six escape constants, and `.claude/ARCHITECTURE.md` records one color source per language with a check behind each, so generating a third spelling from here would break the rule those two checks enforce. What the record is still incomplete about is the other half of those six: `WHITE` and `GREY` name no role below, so the terminal palette is described here in part rather than in whole.',
|
|
84
86
|
].join('\n'),
|
|
85
87
|
|
|
86
88
|
personality: [
|
|
@@ -90,7 +92,9 @@ export const TOKENS: DesignTokens = {
|
|
|
90
92
|
colorNote: [
|
|
91
93
|
'Every role clears WCAG AA at 4.5:1 against each ground it declares, asserted in `src/design/contrast.test.ts`. Two corrections landed with this record becoming the source. The light `muted` step moved from `#7A736A`, which read 4.38 and 4.09 against the two light grounds, and the dark `accent` moved off the `#C8602E` the slide theme carried, which read 4.36 and 3.99 against the two dark ones. Both now sit on the values below.',
|
|
92
94
|
'',
|
|
93
|
-
'
|
|
95
|
+
'Warning and error hold ANSI codes because that is what `scripts/lib/ui.sh` writes and no rendered surface implements an equivalent. Giving either a hex value would invent a mapping no file has, so they carry no contrast reading either.',
|
|
96
|
+
'',
|
|
97
|
+
'Success is the one of the three that does have a rendered equivalent, which is why it carries a hex. `assets/install.html` marks every confirmed step with it, and the shell writes `ANSI 32` for the same role, so the two are one role in two registers rather than one value in two spellings. The hex is what the rendered surface picked and no reading claims the terminal renders that value. It declares `background` alone as its ground, since that is the only role it is drawn on, where every other dark text role is drawn on both.',
|
|
94
98
|
].join('\n'),
|
|
95
99
|
|
|
96
100
|
color: [
|
|
@@ -104,6 +108,11 @@ export const TOKENS: DesignTokens = {
|
|
|
104
108
|
intent: 'cards, panels, raised blocks',
|
|
105
109
|
value: '#211c19',
|
|
106
110
|
},
|
|
111
|
+
{
|
|
112
|
+
role: 'chrome',
|
|
113
|
+
intent: 'the window titlebar, one step above the canvas',
|
|
114
|
+
value: '#241e1a',
|
|
115
|
+
},
|
|
107
116
|
{
|
|
108
117
|
role: 'border',
|
|
109
118
|
intent: 'every rule and panel edge',
|
|
@@ -139,7 +148,12 @@ export const TOKENS: DesignTokens = {
|
|
|
139
148
|
value: '#e0724b',
|
|
140
149
|
grounds: DARK_GROUNDS,
|
|
141
150
|
},
|
|
142
|
-
{
|
|
151
|
+
{
|
|
152
|
+
role: 'success',
|
|
153
|
+
intent: 'confirmations, rendered and in the terminal',
|
|
154
|
+
value: '#61c454',
|
|
155
|
+
grounds: ['background'],
|
|
156
|
+
},
|
|
143
157
|
{ role: 'warning', intent: 'terminal cautions', value: 'ANSI 33' },
|
|
144
158
|
{ role: 'error', intent: 'terminal failures', value: 'ANSI 31' },
|
|
145
159
|
{
|
package/src/gate/measures.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { join } from 'node:path'
|
|
4
4
|
|
|
5
5
|
export interface CommandResult {
|
|
@@ -99,8 +99,8 @@ export const SANDBOX_UNDECLARED_CEILING = 47
|
|
|
99
99
|
*/
|
|
100
100
|
export const AUDITS_BASELINE = '.claude/canon/baseline.json'
|
|
101
101
|
|
|
102
|
-
export const
|
|
103
|
-
'
|
|
102
|
+
export const CAPTURE_STAMP_FAILURE =
|
|
103
|
+
'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets --selector .window and commit each frame with its image and its stamp.'
|
|
104
104
|
|
|
105
105
|
function parseJson(payload: string): unknown {
|
|
106
106
|
try {
|
|
@@ -585,6 +585,27 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
|
|
|
585
585
|
return [...seen].sort()
|
|
586
586
|
}
|
|
587
587
|
|
|
588
|
+
const CAPTURE_DIR = 'assets'
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* Every capture under `assets/`, named by the base its three files share.
|
|
592
|
+
*
|
|
593
|
+
* Read off the folder rather than listed, and off the markup specifically,
|
|
594
|
+
* because that is how `resolveCaptureSources` decides what `canon capture
|
|
595
|
+
* assets` renders. A list would fail open on the frame somebody adds next,
|
|
596
|
+
* which is the one nobody thinks to add here, and driving off the PNGs instead
|
|
597
|
+
* would report a missing set for any image in the folder that is not a capture.
|
|
598
|
+
*/
|
|
599
|
+
function captureBases(root: string): string[] {
|
|
600
|
+
const dir = join(root, CAPTURE_DIR)
|
|
601
|
+
if (!existsSync(dir)) return []
|
|
602
|
+
|
|
603
|
+
return readdirSync(dir)
|
|
604
|
+
.filter((name) => name.endsWith('.html'))
|
|
605
|
+
.map((name) => name.slice(0, -'.html'.length))
|
|
606
|
+
.sort()
|
|
607
|
+
}
|
|
608
|
+
|
|
588
609
|
/**
|
|
589
610
|
* The drift assert on the Hero stage covers the markup because the image beside
|
|
590
611
|
* it is a chromium render whose bytes move with the browser. That leaves the
|
|
@@ -600,36 +621,37 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
|
|
|
600
621
|
*
|
|
601
622
|
* Both digests are checked because either file can move alone. The markup side
|
|
602
623
|
* catches an edit committed with no capture, and the image side catches an
|
|
603
|
-
* image replaced under markup that never changed.
|
|
604
|
-
*
|
|
624
|
+
* image replaced under markup that never changed. A tree carrying no markup
|
|
625
|
+
* under `assets/` has no set to read and passes, which is correct.
|
|
605
626
|
*/
|
|
606
|
-
export const
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
627
|
+
export const captureStamps: Measure = async (ctx) => {
|
|
628
|
+
const lines = captureBases(ctx.root).flatMap((base) =>
|
|
629
|
+
readCaptureSet(ctx.root, base),
|
|
630
|
+
)
|
|
631
|
+
if (lines.length === 0) return { emissions: [] }
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
emissions: [output(lines.join('\n'))],
|
|
635
|
+
failure: CAPTURE_STAMP_FAILURE,
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** One capture set, as the lines it has to report and none where it agrees. */
|
|
640
|
+
function readCaptureSet(root: string, base: string): string[] {
|
|
641
|
+
const set = (['html', 'png', 'stamp'] as const).map(
|
|
642
|
+
(extension) => `${CAPTURE_DIR}/${base}.${extension}`,
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
const missing = set.filter((rel) => !existsSync(join(root, rel)))
|
|
618
646
|
if (missing.length > 0) {
|
|
619
|
-
return {
|
|
620
|
-
emissions: [output(`Missing from the hero set: ${missing.join(' ')}`)],
|
|
621
|
-
failure: HERO_STAMP_FAILURE,
|
|
622
|
-
}
|
|
647
|
+
return [`Missing from the ${base} set: ${missing.join(' ')}`]
|
|
623
648
|
}
|
|
624
649
|
|
|
625
|
-
const [
|
|
626
|
-
|
|
627
|
-
...assertStampField(
|
|
628
|
-
...assertStampField(
|
|
650
|
+
const [html, png, stamp] = set.map((rel) => join(root, rel))
|
|
651
|
+
return [
|
|
652
|
+
...assertStampField(root, stamp, 'source-sha256', html),
|
|
653
|
+
...assertStampField(root, stamp, 'image-sha256', png),
|
|
629
654
|
]
|
|
630
|
-
if (lines.length === 0) return { emissions: [] }
|
|
631
|
-
|
|
632
|
-
return { emissions: [output(lines.join('\n'))], failure: HERO_STAMP_FAILURE }
|
|
633
655
|
}
|
|
634
656
|
|
|
635
657
|
/**
|
package/src/gate/stages.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
auditSet,
|
|
3
|
-
|
|
3
|
+
captureStamps,
|
|
4
4
|
markdownBans,
|
|
5
5
|
type Measure,
|
|
6
6
|
pluginManifests,
|
|
@@ -181,17 +181,21 @@ export const STAGES: readonly Stage[] = [
|
|
|
181
181
|
// render whose bytes move with the browser version, so a drift check over it
|
|
182
182
|
// would fail on a machine whose chromium differs rather than on a stale
|
|
183
183
|
// count. The stamp measure below is what covers the image instead.
|
|
184
|
+
//
|
|
185
|
+
// The stage covers both frames under `assets/` and keeps the narrower name,
|
|
186
|
+
// because the label is spelled across the context entries and the CI table
|
|
187
|
+
// and renaming it buys nothing the pathspec below does not already say.
|
|
184
188
|
id: 'hero',
|
|
185
189
|
label: 'Hero',
|
|
186
190
|
checks: [
|
|
187
191
|
script('regen-hero.sh', 'Hero regen failed'),
|
|
188
192
|
{
|
|
189
193
|
kind: 'drift',
|
|
190
|
-
pathspec: 'assets
|
|
194
|
+
pathspec: 'assets/*.html',
|
|
191
195
|
failure:
|
|
192
|
-
'
|
|
196
|
+
'A generated frame drifted from the catalogs or the design source. Run bun run check, then canon capture assets --selector .window, and commit each assets/*.html with its .png and .stamp.',
|
|
193
197
|
},
|
|
194
|
-
{ kind: 'measure', measure:
|
|
198
|
+
{ kind: 'measure', measure: captureStamps },
|
|
195
199
|
],
|
|
196
200
|
success: 'Hero clean',
|
|
197
201
|
},
|
package/src/github-format.ts
CHANGED
|
@@ -4,6 +4,29 @@ export interface CreateIssueOptions {
|
|
|
4
4
|
labels?: string[]
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Why a `gh issue create` call produced no URL. The two reasons reach the
|
|
9
|
+
* operator as different repairs, which is the whole reason the helper returns a
|
|
10
|
+
* discriminated result rather than a nullable string: an absent binary is
|
|
11
|
+
* something to install, and a failed call is something the stderr explains.
|
|
12
|
+
*/
|
|
13
|
+
export type IssueFailureReason = 'missing-binary' | 'command-failed'
|
|
14
|
+
|
|
15
|
+
export interface IssueFailure {
|
|
16
|
+
readonly ok: false
|
|
17
|
+
readonly reason: IssueFailureReason
|
|
18
|
+
readonly detail?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface IssueSuccess {
|
|
22
|
+
readonly ok: true
|
|
23
|
+
readonly url: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type CreateIssueResult = IssueSuccess | IssueFailure
|
|
27
|
+
|
|
28
|
+
const ISSUE_URL = 'https://github.com/erclx/canon/issues/new'
|
|
29
|
+
|
|
7
30
|
export function buildIssueArgs(opts: CreateIssueOptions): string[] {
|
|
8
31
|
const args = ['issue', 'create', '--title', opts.title, '--body', opts.body]
|
|
9
32
|
for (const label of opts.labels ?? []) {
|
|
@@ -11,3 +34,42 @@ export function buildIssueArgs(opts: CreateIssueOptions): string[] {
|
|
|
11
34
|
}
|
|
12
35
|
return args
|
|
13
36
|
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Collapses to one line. Both callers write the detail inside a framed error or
|
|
40
|
+
* a colored warning, and a newline in the middle of either breaks the frame and
|
|
41
|
+
* leaves the color reset stranded after the last line. `gh` writes multi-line
|
|
42
|
+
* diagnostics routinely, so this is the ordinary case rather than the edge.
|
|
43
|
+
*/
|
|
44
|
+
function oneLine(text: string): string {
|
|
45
|
+
return text.trim().replace(/\s+/g, ' ')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pulls the operator-readable half out of whatever the spawn threw. `gh` writes
|
|
50
|
+
* its own diagnosis to stderr, so that is preferred over the wrapper's message,
|
|
51
|
+
* which names the exit status and nothing about the cause.
|
|
52
|
+
*/
|
|
53
|
+
export function failureDetail(error: unknown): string {
|
|
54
|
+
if (typeof error === 'object' && error !== null) {
|
|
55
|
+
const stderr = (error as { stderr?: unknown }).stderr
|
|
56
|
+
if (typeof stderr === 'string' && stderr.trim()) return oneLine(stderr)
|
|
57
|
+
const short = (error as { shortMessage?: unknown }).shortMessage
|
|
58
|
+
if (typeof short === 'string' && short.trim()) return oneLine(short)
|
|
59
|
+
const message = (error as { message?: unknown }).message
|
|
60
|
+
if (typeof message === 'string' && message.trim()) return oneLine(message)
|
|
61
|
+
}
|
|
62
|
+
return 'gh failed with no diagnostic on stderr'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The sentence the operator reads. It sits beside the argument builder so both
|
|
67
|
+
* reasons are asserted without spawning `gh`, which is the distinction the
|
|
68
|
+
* nullable return left untestable.
|
|
69
|
+
*/
|
|
70
|
+
export function issueFailureMessage(failure: IssueFailure): string {
|
|
71
|
+
if (failure.reason === 'missing-binary') {
|
|
72
|
+
return `gh is not installed, so no issue was filed. Install gh, or file it at ${ISSUE_URL}`
|
|
73
|
+
}
|
|
74
|
+
return `gh could not file the issue: ${failure.detail ?? 'no diagnostic on stderr'}`
|
|
75
|
+
}
|
package/src/github.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import { execa } from 'execa'
|
|
2
2
|
import { gitEnv } from '@/git-env'
|
|
3
3
|
import { PROJECT_ROOT } from '@/project-root'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
buildIssueArgs,
|
|
6
|
+
failureDetail,
|
|
7
|
+
type CreateIssueOptions,
|
|
8
|
+
type CreateIssueResult,
|
|
9
|
+
} from '@/github-format'
|
|
5
10
|
|
|
6
11
|
const GH_TIMEOUT_MS = 30_000
|
|
7
12
|
|
|
8
13
|
export async function createGithubIssue(
|
|
9
14
|
opts: CreateIssueOptions,
|
|
10
|
-
): Promise<
|
|
11
|
-
if (Bun.which('gh') === null) return
|
|
15
|
+
): Promise<CreateIssueResult> {
|
|
16
|
+
if (Bun.which('gh') === null) return { ok: false, reason: 'missing-binary' }
|
|
12
17
|
try {
|
|
13
18
|
// See src/worktrees/reclaim.ts for why gh needs the stripped environment.
|
|
14
19
|
const result = await execa('gh', buildIssueArgs(opts), {
|
|
@@ -17,8 +22,14 @@ export async function createGithubIssue(
|
|
|
17
22
|
env: gitEnv(),
|
|
18
23
|
extendEnv: false,
|
|
19
24
|
})
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return
|
|
25
|
+
const url = result.stdout.trim()
|
|
26
|
+
if (url) return { ok: true, url }
|
|
27
|
+
return {
|
|
28
|
+
ok: false,
|
|
29
|
+
reason: 'command-failed',
|
|
30
|
+
detail: 'gh exited zero and printed no issue URL',
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return { ok: false, reason: 'command-failed', detail: failureDetail(error) }
|
|
23
34
|
}
|
|
24
35
|
}
|