@erclx/canon 5.77.0 → 5.78.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.
Files changed (77) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  4. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  5. package/claude/skills/code-craft/SKILL.md +7 -0
  6. package/claude/skills/code-craft/references/adopted.md +8 -0
  7. package/claude/skills/pr-evidence/SKILL.md +6 -1
  8. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  9. package/claude/skills/pr-evidence/references/reel.md +9 -1
  10. package/claude/skills/review-craft/references/ui.md +13 -1
  11. package/claude/skills/review-pr/references/body-variants.md +2 -0
  12. package/claude/skills/review-pr/references/close-out.md +2 -0
  13. package/claude/skills/review-pr/references/post.md +1 -0
  14. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  15. package/claude/skills/review-ui/SKILL.md +2 -2
  16. package/claude/skills/review-ui/references/driving.md +32 -4
  17. package/claude/skills/review-ui/references/post.md +6 -2
  18. package/claude/skills/role-orchestrator/SKILL.md +2 -2
  19. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  20. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  21. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  22. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  23. package/claude/skills/role-worker/SKILL.md +1 -0
  24. package/claude/skills/test-craft/SKILL.md +1 -0
  25. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  26. package/claude/skills/ui-checklist/SKILL.md +2 -1
  27. package/claude/skills/ui-craft/references/capture.md +16 -0
  28. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  29. package/claude/skills/ui-film/SKILL.md +6 -1
  30. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  31. package/claude/skills/ui-film/references/config.md +2 -2
  32. package/claude/skills/ui-film/references/handoff.md +1 -1
  33. package/claude/skills/ui-film/references/harness.md +71 -0
  34. package/claude/skills/ui-film/references/take-craft.md +42 -17
  35. package/docs/workflow/operating-model.md +6 -5
  36. package/docs/workflow/visual-design-workflow.md +2 -1
  37. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  38. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  39. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  40. package/package.json +4 -3
  41. package/src/commands/pr.ts +117 -31
  42. package/src/commands/take.ts +12 -1
  43. package/src/design/components.ts +5 -107
  44. package/src/design/favicon.ts +21 -17
  45. package/src/design/render.ts +9 -27
  46. package/src/design/template.ts +42 -0
  47. package/src/design/templates/favicon.svg +17 -0
  48. package/src/design/templates/hand-drawn-figure.css +54 -0
  49. package/src/design/templates/preview.html +27 -0
  50. package/src/design/templates/scrollbar.css +29 -0
  51. package/src/design/templates/status.css +24 -0
  52. package/src/gate/embedded.ts +400 -0
  53. package/src/gate/stages.ts +8 -0
  54. package/src/pr/evidence.ts +43 -4
  55. package/src/pr/frames.ts +1 -6
  56. package/src/pr/lift.ts +77 -9
  57. package/src/pr/rest.ts +2 -0
  58. package/src/pr/review-scope.ts +28 -0
  59. package/src/pr/review-state.ts +5 -5
  60. package/src/pr/waiver.ts +45 -0
  61. package/src/take/camera.ts +337 -208
  62. package/src/take/film.ts +7 -1
  63. package/src/take/log.ts +43 -2
  64. package/src/take/perform.ts +85 -13
  65. package/src/take/plan.ts +26 -5
  66. package/src/take/reel.ts +30 -5
  67. package/src/take/screencast.ts +110 -3
  68. package/src/take/stepper.ts +24 -4
  69. package/src/commands/fixtures/gh.sh +0 -64
  70. package/src/take/fixtures/busy.html +0 -107
  71. package/src/take/fixtures/fade.html +0 -49
  72. package/src/take/fixtures/fetch.html +0 -38
  73. package/src/take/fixtures/input.html +0 -138
  74. package/src/take/zoom/NOTICE.md +0 -45
  75. package/src/take/zoom/math-utils.ts +0 -69
  76. package/src/take/zoom/region-utils.ts +0 -55
  77. package/src/take/zoom/spring.ts +0 -133
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ /**
4
+ * A file under `templates/`, resolved from this module so the published CLI
5
+ * finds it wherever the package is installed. Each file ends on one newline
6
+ * that the emitted text does not carry, so that newline is dropped.
7
+ *
8
+ * The folder is outside the formatter, since a reflowed template changes the
9
+ * bytes every generator emits.
10
+ */
11
+ export function readTemplate(file: string): string {
12
+ return readFileSync(
13
+ new URL(`./templates/${file}`, import.meta.url),
14
+ 'utf8',
15
+ ).replace(/\n$/, '')
16
+ }
17
+
18
+ const SLOT = /\{\{(\w+)\}\}/g
19
+
20
+ /**
21
+ * Fills each `{{name}}` slot from `values`. A slot with no value, or a value no
22
+ * slot asks for, throws, so a template and its caller cannot drift apart
23
+ * silently.
24
+ */
25
+ export function fillTemplate(
26
+ template: string,
27
+ values: Readonly<Record<string, string>>,
28
+ ): string {
29
+ const used = new Set<string>()
30
+ const filled = template.replace(SLOT, (_, name: string) => {
31
+ const value = values[name]
32
+ if (value === undefined)
33
+ throw new Error(`template slot {{${name}}} has no value`)
34
+ used.add(name)
35
+ return value
36
+ })
37
+ const unused = Object.keys(values).filter((name) => !used.has(name))
38
+ if (unused.length > 0) {
39
+ throw new Error(`template has no slot for ${unused.join(', ')}`)
40
+ }
41
+ return filled
42
+ }
@@ -0,0 +1,17 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="{{left}} {{top}} {{side}} {{side}}">
2
+ <style>
3
+ .tile { fill: {{tileDark}}; stroke: {{tileDark}}; }
4
+ .c { fill: {{cDark}}; }
5
+ .pip { fill: {{pipDark}}; }
6
+ @media (prefers-color-scheme: light) {
7
+ .tile { fill: {{tileLight}}; stroke: {{edge}}; }
8
+ .c { fill: {{cLight}}; }
9
+ .pip { fill: {{pipLight}}; }
10
+ }
11
+ </style>
12
+ <rect class="tile" x="{{tileX}}" y="{{tileY}}" width="{{tileSide}}" height="{{tileSide}}" rx="{{tileRadius}}" stroke-width="{{edgeWidth}}" />
13
+ <g class="c">
14
+ {{shapes}}
15
+ </g>
16
+ </svg>
17
+
@@ -0,0 +1,54 @@
1
+ /* ---- Figures: hand-drawn, and wider than the measure ---- */
2
+
3
+ :root {
4
+ --figure-hand: 'Virgil', 'Excalifont', cursive;
5
+ --figure-wide: 64rem;
6
+ }
7
+
8
+ figure {
9
+ margin: 2.75rem 0;
10
+ width: var(--figure-wide);
11
+ max-width: 92vw;
12
+ margin-left: 50%;
13
+ transform: translateX(-50%);
14
+ }
15
+
16
+ figure svg {
17
+ width: 100%;
18
+ height: auto;
19
+ display: block;
20
+ }
21
+
22
+ /* CSS beats an SVG presentation attribute, so the diagrams pick up the
23
+ hand face without editing a single lesson. */
24
+ figure svg text {
25
+ font-family: var(--figure-hand);
26
+ }
27
+
28
+ figcaption {
29
+ font-family: var(--figure-hand);
30
+ font-size: 1rem;
31
+ color: var(--color-muted);
32
+ margin: 1rem auto 0;
33
+ line-height: 1.5;
34
+ max-width: 52rem;
35
+ }
36
+
37
+ /* A figure breaks the measure and must still clear an outline rail. Deriving
38
+ the ceiling from the viewport does not hold, since a rail positioned from
39
+ the centre keeps overlapping as the window widens, so the ceiling is fixed
40
+ instead. */
41
+ @media (min-width: 1421px) {
42
+ figure {
43
+ max-width: 54rem;
44
+ }
45
+ }
46
+
47
+ @media (max-width: 640px) {
48
+ figure {
49
+ width: 100%;
50
+ max-width: 100%;
51
+ margin-left: 0;
52
+ transform: none;
53
+ }
54
+ }
@@ -0,0 +1,27 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">{{favicon}}
5
+ <title>Design tokens</title>
6
+ <link rel="stylesheet" href="design.css">
7
+ <style>
8
+ {{chrome}}
9
+ body { font-family: {{bodyStack}}; margin: 2rem; max-width: 960px; color: var(--preview-ink); background: var(--preview-paper); }
10
+ h1 { margin-top: 0; }
11
+ h2 { margin-top: 2rem; border-bottom: 1px solid var(--preview-rule); padding-bottom: 0.25rem; }
12
+ table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
13
+ th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--preview-rule); font-size: 14px; }
14
+ th { background: var(--preview-panel); font-weight: 600; }
15
+ .swatch { display: inline-block; width: 1.5rem; height: 1.5rem; border-radius: 4px; border: 1px solid var(--preview-rule); vertical-align: middle; margin-right: 0.5rem; }
16
+ .bar { display: inline-block; height: 1rem; background: var(--preview-muted); border-radius: 2px; vertical-align: middle; }
17
+ .note { color: var(--preview-muted); font-size: 13px; margin-top: 0.5rem; }
18
+ .empty { color: var(--preview-muted); font-style: italic; }{{verifyStyle}}
19
+ </style>
20
+ </head>
21
+ <body>
22
+ <h1>Design tokens</h1>
23
+ <p class="note">Generated from <code>canon/DESIGN.md</code> by <code>bun run web:previews</code>. Token preview only, not a screen mock.</p>{{summary}}
24
+ {{sections}}
25
+ </body>
26
+ </html>
27
+
@@ -0,0 +1,29 @@
1
+ * {
2
+ scrollbar-width: thin;
3
+ scrollbar-color: var(--color-border) transparent;
4
+ }
5
+
6
+ *::-webkit-scrollbar {
7
+ width: 10px;
8
+ height: 10px;
9
+ }
10
+
11
+ *::-webkit-scrollbar-track {
12
+ background: transparent;
13
+ }
14
+
15
+ *::-webkit-scrollbar-thumb {
16
+ border-radius: var(--radius-marker);
17
+ background: var(--color-border);
18
+ /* Inset by painting a border in the page color, which is what keeps the
19
+ thumb off the edges without a second element. */
20
+ border: 3px solid var(--color-background);
21
+ }
22
+
23
+ *::-webkit-scrollbar-thumb:hover {
24
+ background: var(--color-muted);
25
+ }
26
+
27
+ *::-webkit-scrollbar-corner {
28
+ background: transparent;
29
+ }
@@ -0,0 +1,24 @@
1
+ .status {
2
+ display: inline-flex;
3
+ align-items: center;
4
+ gap: 0.42rem;
5
+ font-size: 0.75rem;
6
+ font-weight: 500;
7
+ letter-spacing: 0;
8
+ text-transform: none;
9
+ color: var(--color-muted);
10
+ white-space: nowrap;
11
+ }
12
+
13
+ .status::before {
14
+ content: '';
15
+ width: 6px;
16
+ height: 6px;
17
+ flex: none;
18
+ border-radius: var(--radius-marker);
19
+ background: var(--color-border);
20
+ }
21
+
22
+ .status.is-done::before {
23
+ background: var(--color-accent);
24
+ }
@@ -0,0 +1,400 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { plural } from '@/cli/ui'
4
+ import type { Emission, Measure } from '@/gate/measures'
5
+ import { listRepositoryFiles } from '@/git/files'
6
+ import { isMarked } from '@/sweep/exempt-marker'
7
+
8
+ export const EMBEDDED_MARKER = 'canon-allow-embedded'
9
+
10
+ /**
11
+ * Static lines a page, stylesheet, or script may hold inline. Matches the
12
+ * limit `governance/rules/tooling/ci-workflow.md` sets on a `run:` step, so a
13
+ * sixth line is where a block leaves for its own file.
14
+ */
15
+ export const EMBEDDED_LINE_LIMIT = 5
16
+
17
+ export type EmbeddedKind = 'page' | 'stylesheet' | 'script'
18
+
19
+ export interface EmbeddedBlock {
20
+ readonly file: string
21
+ readonly line: number
22
+ readonly kind: EmbeddedKind
23
+ readonly lines: number
24
+ /** A script that branches counts at any length, so the finding says which. */
25
+ readonly branches: boolean
26
+ }
27
+
28
+ interface Literal {
29
+ readonly line: number
30
+ /** Offsets of the opening quote and just past the closing one. */
31
+ readonly start: number
32
+ readonly end: number
33
+ /**
34
+ * The literal's text with `\n` decoded and each `${}` expression replaced by
35
+ * `INTERPOLATION`, so a line carrying one can be told from a static line.
36
+ */
37
+ readonly text: string
38
+ }
39
+
40
+ const INTERPOLATION = '\u0000'
41
+
42
+ /** Characters after which a `/` opens a regular expression rather than divides. */
43
+ const REGEX_PRECEDERS = new Set('(,=:[!&|?{};+-*%<>~^'.split(''))
44
+ const REGEX_KEYWORDS =
45
+ /(?:^|[^\w$])(?:return|typeof|case|do|else|in|of|new|delete|void|throw|yield|await)$/
46
+
47
+ /**
48
+ * Finds every string and template literal in a TypeScript source.
49
+ *
50
+ * A pattern over backticks reads a doc comment saying `then` as a script, so
51
+ * the stage needs literal bounds. A syntax tree would give them too, but
52
+ * `typescript` is a development dependency and this module ships inside the
53
+ * published CLI, so a lexer tracking comment, string, regex, and template
54
+ * state stands in for it.
55
+ */
56
+ function lex(source: string): {
57
+ readonly literals: Literal[]
58
+ /** Each comment's start, keyed by the end of its text. */
59
+ readonly comments: ReadonlyMap<number, number>
60
+ } {
61
+ const literals: Literal[] = []
62
+ const comments = new Map<number, number>()
63
+ // One entry per open template expression, holding its brace depth.
64
+ const expressions: number[] = []
65
+ // One entry per open template, holding its start line and static text.
66
+ const templates: { line: number; start: number; text: string }[] = []
67
+ let line = 1
68
+ let i = 0
69
+ let lastSignificant = ''
70
+
71
+ const readQuoted = (quote: string): void => {
72
+ const opening = line
73
+ const start = i
74
+ let text = ''
75
+ i++
76
+ while (i < source.length && source[i] !== quote && source[i] !== '\n') {
77
+ if (source[i] === '\\') {
78
+ text += source[i + 1] === 'n' ? '\n' : (source[i + 1] ?? '')
79
+ if (source[i + 1] === '\n') line++
80
+ i += 2
81
+ continue
82
+ }
83
+ text += source[i]
84
+ i++
85
+ }
86
+ i++
87
+ literals.push({ line: opening, start, end: i, text })
88
+ }
89
+
90
+ // Reads template text until the closing backtick or the next `${`.
91
+ const readTemplate = (): void => {
92
+ const current = templates[templates.length - 1]
93
+ if (current === undefined) return
94
+ while (i < source.length) {
95
+ const char = source[i]
96
+ if (char === '\\') {
97
+ current.text += source[i + 1] === 'n' ? '\n' : (source[i + 1] ?? '')
98
+ if (source[i + 1] === '\n') line++
99
+ i += 2
100
+ continue
101
+ }
102
+ if (char === '`') {
103
+ i++
104
+ templates.pop()
105
+ literals.push({
106
+ line: current.line,
107
+ start: current.start,
108
+ end: i,
109
+ text: current.text,
110
+ })
111
+ lastSignificant = '`'
112
+ return
113
+ }
114
+ if (char === '$' && source[i + 1] === '{') {
115
+ current.text += INTERPOLATION
116
+ i += 2
117
+ expressions.push(0)
118
+ lastSignificant = '{'
119
+ return
120
+ }
121
+ if (char === '\n') line++
122
+ current.text += char
123
+ i++
124
+ }
125
+ }
126
+
127
+ const readRegex = (): void => {
128
+ let inClass = false
129
+ i++
130
+ while (i < source.length && source[i] !== '\n') {
131
+ const char = source[i]
132
+ if (char === '\\') {
133
+ i += 2
134
+ continue
135
+ }
136
+ if (char === '[') inClass = true
137
+ else if (char === ']') inClass = false
138
+ else if (char === '/' && !inClass) break
139
+ i++
140
+ }
141
+ i++
142
+ while (i < source.length && /[a-z]/.test(source[i] ?? '')) i++
143
+ lastSignificant = 'a'
144
+ }
145
+
146
+ while (i < source.length) {
147
+ const char = source[i] ?? ''
148
+ const next = source[i + 1]
149
+
150
+ if (char === '\n') {
151
+ line++
152
+ i++
153
+ continue
154
+ }
155
+ if (char === '/' && next === '/') {
156
+ const start = i
157
+ while (i < source.length && source[i] !== '\n') i++
158
+ comments.set(start + source.slice(start, i).trimEnd().length, start)
159
+ continue
160
+ }
161
+ if (char === '/' && next === '*') {
162
+ const end = source.indexOf('*/', i + 2)
163
+ const stop = end === -1 ? source.length : end + 2
164
+ for (let k = i; k < stop; k++) if (source[k] === '\n') line++
165
+ comments.set(stop, i)
166
+ i = stop
167
+ continue
168
+ }
169
+ if (char === "'" || char === '"') {
170
+ readQuoted(char)
171
+ lastSignificant = char
172
+ continue
173
+ }
174
+ if (char === '`') {
175
+ templates.push({ line, start: i, text: '' })
176
+ i++
177
+ readTemplate()
178
+ continue
179
+ }
180
+ if (char === '/') {
181
+ const before = source.slice(Math.max(0, i - 12), i).trimEnd()
182
+ if (
183
+ lastSignificant === '' ||
184
+ REGEX_PRECEDERS.has(lastSignificant) ||
185
+ REGEX_KEYWORDS.test(before)
186
+ ) {
187
+ readRegex()
188
+ continue
189
+ }
190
+ }
191
+ if (expressions.length > 0) {
192
+ const depth = expressions.length - 1
193
+ if (char === '{') expressions[depth] = (expressions[depth] ?? 0) + 1
194
+ if (char === '}') {
195
+ if (expressions[depth] === 0) {
196
+ expressions.pop()
197
+ i++
198
+ readTemplate()
199
+ continue
200
+ }
201
+ expressions[depth] = (expressions[depth] ?? 0) - 1
202
+ }
203
+ }
204
+ if (!/\s/.test(char)) lastSignificant = /[\w$)\]]/.test(char) ? 'a' : char
205
+ i++
206
+ }
207
+
208
+ return { literals, comments }
209
+ }
210
+
211
+ const PAGE_OPENER = /^\s*<(?:!doctype|html|head|body)\b/i
212
+ // An HTML element name, so markup in a format of the project's own, such as a
213
+ // `<rule>` payload, is not read as a page.
214
+ const MARKUP_LINE =
215
+ /^\s*<\/?(?:a|article|button|canvas|code|details|dialog|div|figure|footer|form|h[1-6]|header|iframe|img|input|label|li|link|main|meta|nav|ol|option|p|pre|script|section|select|span|style|summary|svg|table|td|template|textarea|th|title|tr|ul)[\s>/]/i
216
+ const CLOSING_TAG = /<\/[a-z][\w-]*>/i
217
+ const SELECTOR_LINE = /^\s*[^\s{}'"`/][^{}'"`;]*\{\s*$/
218
+ const DECLARATION_LINE = /^\s*-{0,2}[a-z][a-z-]*\s*:\s*[^;{}]+;\s*$/
219
+ const SHEBANG = /^\s*#!\//
220
+ // Each opener needs its own keyword and closer, so prose that opens a line on
221
+ // `for` and ends another on `done` is not read as a script.
222
+ const SHELL_BRANCHES: readonly RegExp[] = [
223
+ /(?:^\s*|[;&|]\s*)(?:if|elif)\s[\s\S]*?\bthen\b[\s\S]*?(?:^|[;\s])fi\s*(?:$|[;)|&<>])/m,
224
+ /(?:^\s*|[;&|]\s*)(?:for|while|until)\s[\s\S]*?\bdo\b[\s\S]*?(?:^|[;\s])done\s*(?:$|[;)|&<>])/m,
225
+ /(?:^\s*|[;&|]\s*)case\s[\s\S]*?\bin\b[\s\S]*?(?:^|[;\s])esac\s*(?:$|[;)|&<>])/m,
226
+ ]
227
+ const NEWLINE_JOIN = /^\]\s*\.join\(\s*(['"`])\\n\1\s*\)/
228
+
229
+ /**
230
+ * Lines carrying content and no interpolation. A leading newline counts for
231
+ * nothing, and a line built from data is code generating output, so only the
232
+ * static part of a template is weighed against the limit.
233
+ */
234
+ function staticLines(text: string): number {
235
+ return text
236
+ .split('\n')
237
+ .filter((line) => line.trim() !== '' && !line.includes(INTERPOLATION))
238
+ .length
239
+ }
240
+
241
+ /** The language a literal's static text is written in, or none of the three. */
242
+ function kindOf(
243
+ text: string,
244
+ ): { kind: EmbeddedKind; branches: boolean } | undefined {
245
+ const branches = SHELL_BRANCHES.some((branch) => branch.test(text))
246
+ if (SHEBANG.test(text) || branches) return { kind: 'script', branches }
247
+ const lines = text.split('\n').filter((line) => line.trim() !== '')
248
+ if (
249
+ PAGE_OPENER.test(text) ||
250
+ (CLOSING_TAG.test(text) && MARKUP_LINE.test(lines[0] ?? ''))
251
+ ) {
252
+ return { kind: 'page', branches: false }
253
+ }
254
+ if (
255
+ lines.some((line) => SELECTOR_LINE.test(line)) &&
256
+ lines.some((line) => DECLARATION_LINE.test(line))
257
+ ) {
258
+ return { kind: 'stylesheet', branches: false }
259
+ }
260
+ return undefined
261
+ }
262
+
263
+ /** The offset of the next token, past whitespace and comments. */
264
+ function skipTrivia(source: string, from: number): number {
265
+ let i = from
266
+ while (i < source.length) {
267
+ if (/\s/.test(source[i] ?? '')) {
268
+ i++
269
+ } else if (source.startsWith('//', i)) {
270
+ const end = source.indexOf('\n', i)
271
+ i = end === -1 ? source.length : end + 1
272
+ } else if (source.startsWith('/*', i)) {
273
+ const end = source.indexOf('*/', i + 2)
274
+ i = end === -1 ? source.length : end + 2
275
+ } else {
276
+ return i
277
+ }
278
+ }
279
+ return i
280
+ }
281
+
282
+ /**
283
+ * Merges each array of literals closed by a newline join into one block opening
284
+ * on the array's bracket, since a script written line by line is one program and
285
+ * each of its literals alone reads as nothing. An array holding anything but
286
+ * literals stays as its separate literals.
287
+ */
288
+ function blocksOf(
289
+ source: string,
290
+ literals: readonly Literal[],
291
+ comments: ReadonlyMap<number, number>,
292
+ ): Literal[] {
293
+ // The offset of the last token before `from`, past whitespace and comments.
294
+ const tokenBefore = (from: number): number => {
295
+ let at = from
296
+ for (;;) {
297
+ while (at > 0 && /\s/.test(source[at - 1] ?? '')) at--
298
+ const start = comments.get(at)
299
+ if (start === undefined) return at - 1
300
+ at = start
301
+ }
302
+ }
303
+
304
+ const byStart = new Map(literals.map((literal) => [literal.start, literal]))
305
+ const merged = new Set<Literal>()
306
+ const blocks: Literal[] = []
307
+
308
+ for (const first of literals) {
309
+ if (merged.has(first)) continue
310
+ const bracket = tokenBefore(first.start)
311
+ if (source[bracket] !== '[') continue
312
+
313
+ const run: Literal[] = []
314
+ let next: Literal | undefined = first
315
+ let cursor = first.start
316
+ while (next !== undefined) {
317
+ run.push(next)
318
+ cursor = skipTrivia(source, next.end)
319
+ if (source[cursor] === ',') cursor = skipTrivia(source, cursor + 1)
320
+ next = byStart.get(cursor)
321
+ }
322
+ if (!NEWLINE_JOIN.test(source.slice(cursor, cursor + 40))) continue
323
+
324
+ for (const literal of run) merged.add(literal)
325
+ blocks.push({
326
+ line: source.slice(0, bracket).split('\n').length,
327
+ start: bracket,
328
+ end: cursor,
329
+ text: run.map((literal) => literal.text).join('\n'),
330
+ })
331
+ }
332
+
333
+ return [
334
+ ...blocks,
335
+ ...literals.filter((literal) => !merged.has(literal)),
336
+ ].sort((a, b) => a.start - b.start)
337
+ }
338
+
339
+ /**
340
+ * The pages, stylesheets, and scripts a source embeds past the limit, or a
341
+ * script that branches at any length.
342
+ *
343
+ * Only a template's static lines count, so a long page with one interpolation
344
+ * still counts and a block built line by line from data does not. A literal whose
345
+ * opening line, or the line above it, carries the marker with a reason is
346
+ * exempt, and a bare marker exempts nothing.
347
+ */
348
+ export function embeddedBlocksIn(
349
+ file: string,
350
+ source: string,
351
+ ): EmbeddedBlock[] {
352
+ const sourceLines = source.split('\n')
353
+ const blocks: EmbeddedBlock[] = []
354
+ const { literals, comments } = lex(source)
355
+ for (const literal of blocksOf(source, literals, comments)) {
356
+ const found = kindOf(literal.text.replaceAll(INTERPOLATION, ''))
357
+ if (found === undefined) continue
358
+ const lines = staticLines(literal.text)
359
+ if (lines <= EMBEDDED_LINE_LIMIT && !found.branches) continue
360
+ if (isMarked(sourceLines, literal.line - 1, EMBEDDED_MARKER)) continue
361
+ blocks.push({ file, line: literal.line, lines, ...found })
362
+ }
363
+ return blocks
364
+ }
365
+
366
+ const SOURCE_FILE = /\.(?:ts|tsx|mts|cts)$/
367
+
368
+ export const embeddedPrograms: Measure = async (ctx) => {
369
+ const files = await listRepositoryFiles(ctx.root)
370
+ if (files === undefined) {
371
+ return {
372
+ emissions: [],
373
+ unmeasured:
374
+ 'The tracked file list could not be read, so no source was scanned for embedded blocks.',
375
+ }
376
+ }
377
+
378
+ const sources = files.filter((path) => SOURCE_FILE.test(path))
379
+ const blocks = sources.flatMap((path) =>
380
+ embeddedBlocksIn(path, readFileSync(join(ctx.root, path), 'utf8')),
381
+ )
382
+
383
+ if (blocks.length === 0) {
384
+ const emissions: Emission[] = [
385
+ {
386
+ kind: 'info',
387
+ text: `No page, stylesheet, or script embedded past ${EMBEDDED_LINE_LIMIT} lines across ${plural(sources.length, 'source file')}`,
388
+ },
389
+ ]
390
+ return { emissions }
391
+ }
392
+
393
+ return {
394
+ emissions: blocks.map((block) => ({
395
+ kind: 'warn',
396
+ text: `${block.file}:${block.line} embeds ${block.branches ? 'a branching script' : `a ${block.kind} of ${plural(block.lines, 'line')}`}`,
397
+ })),
398
+ failure: `${plural(blocks.length, 'embedded block')} past the limit. Move each into its own file with its language's extension, read from disk, or mark the opening line with "${EMBEDDED_MARKER}: <reason>" where it must stay inline.`,
399
+ }
400
+ }
@@ -5,6 +5,7 @@ import {
5
5
  seedIndependence,
6
6
  skillPaths,
7
7
  } from '@/gate/boundaries'
8
+ import { embeddedPrograms } from '@/gate/embedded'
8
9
  import {
9
10
  canonicalRecords,
10
11
  auditSet,
@@ -366,6 +367,13 @@ export const STAGES: readonly Stage[] = [
366
367
  label: 'Folder-echoed filenames',
367
368
  checks: [{ kind: 'measure', measure: folderEchoedNames }],
368
369
  },
370
+ {
371
+ // Unscoped, since a block one branch embeds stays embedded on every later
372
+ // branch, and reading every tracked source costs well under a second.
373
+ id: 'embedded-programs',
374
+ label: 'Embedded programs',
375
+ checks: [{ kind: 'measure', measure: embeddedPrograms }],
376
+ },
369
377
  {
370
378
  // A rule citing a file that moved fails silently. The consumed-copy drift
371
379
  // stage passes an authored rule and its copy that are wrong together, and