@erclx/canon 4.60.0 → 4.62.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.
@@ -0,0 +1,110 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { relative } from 'node:path'
3
+ import { archiveDir, listTaskStems, tasksDir } from '@/tasks/archive'
4
+
5
+ /** Every label in the corpus today stops here before rolling to the next major. */
6
+ const MINOR_ROLLOVER = 9
7
+
8
+ /** The label a board with no live or archived task yet allocates first. */
9
+ const FIRST_LABEL: Label = { major: 1, minor: 0 }
10
+
11
+ /**
12
+ * Matches a task filename stem's leading label. Anchored, so a sibling such as
13
+ * `TASK-ARCHIVE` fails it outright, and a reserved stem such as `index` or
14
+ * `priority` never reaches it at all, since `listTaskStems` already filters
15
+ * those out before this pattern sees a stem.
16
+ */
17
+ export const LABEL_PATTERN = /^v(\d+)\.(\d+)-/
18
+
19
+ interface Label {
20
+ readonly major: number
21
+ readonly minor: number
22
+ }
23
+
24
+ export interface NextLabel {
25
+ readonly ok: true
26
+ readonly label: string
27
+ /** The label this run was derived from, absent when neither folder holds one. */
28
+ readonly highest: string | undefined
29
+ }
30
+
31
+ export interface LabelRefused {
32
+ readonly ok: false
33
+ readonly reason: 'no-board'
34
+ readonly message: string
35
+ }
36
+
37
+ export type LabelOutcome = NextLabel | LabelRefused
38
+
39
+ function parseLabel(stem: string): Label | undefined {
40
+ const match = LABEL_PATTERN.exec(stem)
41
+ if (!match) return undefined
42
+
43
+ return { major: Number(match[1]), minor: Number(match[2]) }
44
+ }
45
+
46
+ function isHigher(candidate: Label, current: Label): boolean {
47
+ return candidate.major !== current.major
48
+ ? candidate.major > current.major
49
+ : candidate.minor > current.minor
50
+ }
51
+
52
+ function formatLabel(label: Label): string {
53
+ return `v${String(label.major).padStart(2, '0')}.${label.minor}`
54
+ }
55
+
56
+ /**
57
+ * The label after the one given, rolling a minor of 9 to the next major rather
58
+ * than continuing to a second minor digit. Every one of the 587 labels measured
59
+ * across the live board and its archive on 2026-09-06 stops at a single digit,
60
+ * so this is the rollover the whole corpus already follows rather than a rule
61
+ * this verb introduces.
62
+ */
63
+ function next(label: Label): Label {
64
+ return label.minor >= MINOR_ROLLOVER
65
+ ? { major: label.major + 1, minor: 0 }
66
+ : { major: label.major, minor: label.minor + 1 }
67
+ }
68
+
69
+ /**
70
+ * Reports the next unused phase label, read off the true maximum across
71
+ * `.canon/tasks/` and its `archive/` sibling together. A scan confined to the
72
+ * live board is blind to every label the archive already spent, which is what
73
+ * let two sessions hand out the same label within minutes of each other.
74
+ *
75
+ * It reports and never writes. Two sessions calling it in the same second can
76
+ * still take the same answer, since the board is gitignored files rather than
77
+ * a store with a lock, and `standards/versioning.md` permits free renumbering,
78
+ * so a collision costs a rename rather than anything worse. A duplicate label
79
+ * already sitting in the tree, and a gap left by a renumbering, both fold into
80
+ * the same max scan without needing a dedicated check.
81
+ */
82
+ export async function nextLabel(root: string): Promise<LabelOutcome> {
83
+ const dir = tasksDir(root)
84
+
85
+ if (!existsSync(dir)) {
86
+ return {
87
+ ok: false,
88
+ reason: 'no-board',
89
+ message: `No task board at ${relative(root, dir)}.`,
90
+ }
91
+ }
92
+
93
+ const archive = archiveDir(root)
94
+ const dirs = existsSync(archive) ? [dir, archive] : [dir]
95
+ const stems = (await Promise.all(dirs.map((d) => listTaskStems(d)))).flat()
96
+
97
+ const highest = stems
98
+ .map(parseLabel)
99
+ .filter((label): label is Label => label !== undefined)
100
+ .reduce<Label | undefined>(
101
+ (max, label) => (max === undefined || isHigher(label, max) ? label : max),
102
+ undefined,
103
+ )
104
+
105
+ return {
106
+ ok: true,
107
+ label: formatLabel(highest === undefined ? FIRST_LABEL : next(highest)),
108
+ highest: highest === undefined ? undefined : formatLabel(highest),
109
+ }
110
+ }
@@ -59,6 +59,12 @@ Append to the `## Scripts` table:
59
59
 
60
60
  In `.claude/context/ci.md`, the Typecheck row's assertion reads: `` `astro check` passes ``. The Build row's assertion reads: `` `astro build` succeeds ``.
61
61
 
62
+ ## Gitignore (extend)
63
+
64
+ `[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
65
+
66
+ - `"# Astro" = [".astro/"]`
67
+
62
68
  ## Scenario switcher
63
69
 
64
70
  - `src/components/dev/scenarios.astro` ships as a golden config, always overwritten on sync, since it is toolkit-authored infrastructure rather than a file a project hand-edits.
@@ -9,11 +9,19 @@ interface CaptureCase {
9
9
  route: string
10
10
  width: number
11
11
  height: number
12
+ evidence?: boolean
12
13
  setup?: (page: Page) => Promise<void>
13
14
  }
14
15
 
15
16
  const CASES: CaptureCase[] = [
16
- { section: 'home', theme: 'default', route: '/', width: 1280, height: 800 },
17
+ {
18
+ section: 'home',
19
+ theme: 'default',
20
+ route: '/',
21
+ width: 1280,
22
+ height: 800,
23
+ evidence: true,
24
+ },
17
25
  {
18
26
  section: 'home',
19
27
  theme: 'dark',
@@ -47,8 +55,12 @@ const OUT_DIR = path.join('screenshots', hostname)
47
55
 
48
56
  const browser = await chromium.launch()
49
57
  const consoleErrors: string[] = []
58
+ let ranCases = 0
50
59
 
51
60
  for (const captureCase of CASES) {
61
+ if (captureCase.evidence && requireBaseUrl) continue
62
+
63
+ ranCases++
52
64
  const context = await browser.newContext({
53
65
  viewport: { width: captureCase.width, height: captureCase.height },
54
66
  })
@@ -76,11 +88,26 @@ for (const captureCase of CASES) {
76
88
  await page.screenshot({ path: file, fullPage: true })
77
89
  console.log(`captured ${file}`)
78
90
 
91
+ if (captureCase.evidence) {
92
+ const evidenceDir = path.join('evidence', captureCase.section)
93
+ await mkdir(evidenceDir, { recursive: true })
94
+ const evidenceFile = path.join(evidenceDir, `${captureCase.theme}.png`)
95
+ await page.screenshot({ path: evidenceFile, fullPage: true })
96
+ console.log(`captured ${evidenceFile}`)
97
+ }
98
+
79
99
  await context.close()
80
100
  }
81
101
 
82
102
  await browser.close()
83
103
 
104
+ if (requireBaseUrl && ranCases === 0) {
105
+ console.error(
106
+ 'every CASES entry is flagged evidence: true, so --require-base-url skipped all of them and checked nothing',
107
+ )
108
+ process.exit(1)
109
+ }
110
+
84
111
  if (checkConsoleClean && consoleErrors.length > 0) {
85
112
  console.error('console errors detected:')
86
113
  for (const error of consoleErrors) console.error(` ${error}`)
@@ -57,4 +57,5 @@ packages = [
57
57
  "# Build" = ["dist/"]
58
58
  "# Coverage" = ["coverage/"]
59
59
  "# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]
60
+ "# Screenshots" = ["screenshots/"]
60
61
  "# VSCode" = [".vscode/*", "!.vscode/extensions.json", "!.vscode/settings.json"]
@@ -12,7 +12,7 @@ Golden config files live in `tooling/web/configs/` and are copied into the targe
12
12
 
13
13
  - `eslint.config.js`: flat config with `@eslint/js`, `typescript-eslint`, React hooks, import sort, check-file, vitest rules scoped to test files, `eslint-config-prettier` last.
14
14
  - `src/test/setup.ts`: `@testing-library/jest-dom` import, `cleanup` after each test.
15
- - `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
15
+ - `e2e/screenshot.ts`: capture template. A single `CASES` record at the top carries one entry per output file, each naming a section, a theme, a route, and its own viewport, and the loop below writes `screenshots/<hostname>/<section>/<theme>.png`, keyed on `SCREENSHOT_BASE_URL`'s hostname so a local and a deployed run land in different folders. A case flagged `evidence: true` additionally writes `evidence/<section>/<theme>.png`, with no hostname segment. `--require-base-url` skips a flagged case entirely, so a production smoke run neither writes to the committed path nor counts an evidence route in its console-clean check, and exits 1 when that leaves zero cases run, so flagging every case cannot silence the production check without saying so. Per-project cases extend the one record. A route's themes sit together under its section folder, so the filename carries the theme alone. `--check-console-clean` collects `console`-level error messages per case and exits 1 with the list if any fired, turning the capture into a smoke check. `--require-base-url` exits 1 before launching a browser when `SCREENSHOT_BASE_URL` is unset, guarding a script meant to run against a real deployment from silently capturing `localhost`.
16
16
  - `.vscode/extensions.json` and `.vscode/settings.json`: editor wiring for ESLint, Tailwind, Playwright, Vitest.
17
17
  - `.github/workflows/verify.yml`: `static-checks`, `unit-tests`, `build-verify`, and `e2e-tests` jobs.
18
18
  - `scripts/verify.sh`: extends base verify with typecheck, lint, unit tests, and build in the full order.
@@ -112,13 +112,14 @@ Append rows:
112
112
 
113
113
  `governance/rules/ui/440-surface-capture.md` is what asks a session to run the capture after a route changes. It fires on route and page files rather than on every component, so a shared component changing every screen fires nothing and the operator runs the capture by hand.
114
114
 
115
- The screenshot output now tracks in git rather than getting discarded, so the first capture a scaffolded target runs after this change is the baseline it commits.
115
+ The sweep under `screenshots/` is ignored again, and only a flagged case's `evidence/` output tracks in git, so the first capture a scaffolded target runs after this change is the baseline it commits there.
116
116
 
117
117
  ## Gitignore (extend)
118
118
 
119
119
  `[gitignore]` groups this stack edits, restated here per the manifest-to-reference symmetry:
120
120
 
121
121
  - `"# Playwright" = ["test-results/", "playwright-report/", "blob-report/", "playwright/.cache/"]`
122
+ - `"# Screenshots" = ["screenshots/"]`
122
123
 
123
124
  ## Verify script
124
125