@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.
@@ -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')
@@ -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'] {
@@ -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
- 'Two surfaces still carry their own copies. The rendered hero at `assets/hero.html` is written by `scripts/core/regen-hero.sh` against a committed capture, and the terminal framing in `scripts/lib/ui.sh` and `src/ui.ts` writes ANSI rather than hex. The dark half below is the palette the hero carries, so the two agree today by value and not yet by construction.',
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
- 'Success, warning, and error hold ANSI codes because that is what `scripts/lib/ui.sh` writes, and no rendered surface implements an equivalent. Giving them a hex value would invent a mapping no file has, so they carry no contrast reading either.',
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
- { role: 'success', intent: 'terminal confirmations', value: 'ANSI 32' },
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
  {
@@ -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 HERO_STAMP_FAILURE =
103
- 'The hero set disagrees with the stamp written when the image was captured. Run canon capture assets/hero.html --selector .window and commit all three files together.'
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. All three absent passes,
604
- * which is correct for a tree that carries none of them.
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 heroStamp: Measure = async (ctx) => {
607
- const set = [
608
- ['assets/hero.html', join(ctx.root, 'assets/hero.html')],
609
- ['assets/hero.png', join(ctx.root, 'assets/hero.png')],
610
- ['assets/hero.stamp', join(ctx.root, 'assets/hero.stamp')],
611
- ] as const
612
-
613
- if (set.every(([, path]) => !existsSync(path))) return { emissions: [] }
614
-
615
- const missing = set
616
- .filter(([, path]) => !existsSync(path))
617
- .map(([label]) => label)
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 [[, html], [, png], [, stamp]] = set
626
- const lines = [
627
- ...assertStampField(ctx.root, stamp, 'source-sha256', html),
628
- ...assertStampField(ctx.root, stamp, 'image-sha256', png),
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
  /**
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  auditSet,
3
- heroStamp,
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/hero.html',
194
+ pathspec: 'assets/*.html',
191
195
  failure:
192
- 'Hero counts drifted. Run bun run check, then canon capture assets/hero.html --selector .window, and commit assets/hero.html with assets/hero.png and assets/hero.stamp.',
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: heroStamp },
198
+ { kind: 'measure', measure: captureStamps },
195
199
  ],
196
200
  success: 'Hero clean',
197
201
  },
@@ -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 { buildIssueArgs, type CreateIssueOptions } from '@/github-format'
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<string | null> {
11
- if (Bun.which('gh') === null) return null
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
- return result.stdout.trim() || null
21
- } catch {
22
- return null
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
  }