@erclx/canon 5.77.0 → 5.79.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 (125) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/claude/.claude-plugin/plugin.json +1 -1
  3. package/claude/skills/app-drive/REQUIREMENT.md +12 -0
  4. package/claude/skills/app-drive/SKILL.md +44 -8
  5. package/claude/skills/app-drive/references/config.md +18 -8
  6. package/claude/skills/app-drive/references/drive-record.md +4 -0
  7. package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
  8. package/claude/skills/auto-ship/SKILL.md +5 -4
  9. package/claude/skills/auto-ship/references/draft-mark.md +33 -15
  10. package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
  11. package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
  12. package/claude/skills/code-craft/REQUIREMENT.md +1 -1
  13. package/claude/skills/code-craft/SKILL.md +7 -0
  14. package/claude/skills/code-craft/references/adopted.md +8 -0
  15. package/claude/skills/git-followup/SKILL.md +1 -0
  16. package/claude/skills/git-pr/REQUIREMENT.md +2 -0
  17. package/claude/skills/git-pr/SKILL.md +2 -0
  18. package/claude/skills/git-pr/references/final-command.md +34 -2
  19. package/claude/skills/git-pr/references/pr.md +1 -1
  20. package/claude/skills/git-ship/REQUIREMENT.md +2 -2
  21. package/claude/skills/git-ship/SKILL.md +16 -11
  22. package/claude/skills/git-ship/references/ci-watch.md +28 -4
  23. package/claude/skills/markdown-craft/references/markdown.md +4 -4
  24. package/claude/skills/plan-feature/references/plan.md +2 -0
  25. package/claude/skills/pr-evidence/SKILL.md +6 -1
  26. package/claude/skills/pr-evidence/references/evidence.md +1 -0
  27. package/claude/skills/pr-evidence/references/reel.md +9 -1
  28. package/claude/skills/review-address/SKILL.md +2 -1
  29. package/claude/skills/review-craft/references/ui.md +13 -1
  30. package/claude/skills/review-pr/references/body-variants.md +2 -0
  31. package/claude/skills/review-pr/references/close-out.md +2 -0
  32. package/claude/skills/review-pr/references/key-changes.md +2 -1
  33. package/claude/skills/review-pr/references/post.md +1 -0
  34. package/claude/skills/review-ui/REQUIREMENT.md +1 -0
  35. package/claude/skills/review-ui/SKILL.md +2 -2
  36. package/claude/skills/review-ui/references/driving.md +32 -4
  37. package/claude/skills/review-ui/references/post.md +6 -2
  38. package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
  39. package/claude/skills/role-orchestrator/SKILL.md +4 -4
  40. package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
  41. package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
  42. package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
  43. package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
  44. package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
  45. package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
  46. package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
  47. package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
  48. package/claude/skills/role-worker/SKILL.md +2 -1
  49. package/claude/skills/task-board/SKILL.md +1 -0
  50. package/claude/skills/task-board/references/tasks.md +1 -0
  51. package/claude/skills/test-craft/SKILL.md +1 -0
  52. package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
  53. package/claude/skills/ui-checklist/SKILL.md +2 -1
  54. package/claude/skills/ui-craft/references/capture.md +16 -0
  55. package/claude/skills/ui-film/REQUIREMENT.md +2 -2
  56. package/claude/skills/ui-film/SKILL.md +6 -1
  57. package/claude/skills/ui-film/references/compose-craft.md +68 -11
  58. package/claude/skills/ui-film/references/config.md +2 -2
  59. package/claude/skills/ui-film/references/handoff.md +1 -1
  60. package/claude/skills/ui-film/references/harness.md +71 -0
  61. package/claude/skills/ui-film/references/take-craft.md +42 -17
  62. package/docs/workflow/ai-workflow.md +1 -1
  63. package/docs/workflow/operating-model.md +6 -5
  64. package/docs/workflow/parallel-features.md +2 -1
  65. package/docs/workflow/visual-design-workflow.md +2 -1
  66. package/governance/ledger/skills/code-craft/SKILL.md +9 -0
  67. package/governance/ledger/skills/test-craft/SKILL.md +1 -0
  68. package/governance/ledger/skills/ui-craft/capture.md +18 -5
  69. package/package.json +4 -3
  70. package/src/commands/pr.ts +255 -46
  71. package/src/commands/sessions.ts +88 -6
  72. package/src/commands/take.ts +12 -1
  73. package/src/commands/tasks.ts +41 -2
  74. package/src/design/components.ts +5 -107
  75. package/src/design/favicon.ts +21 -17
  76. package/src/design/render.ts +9 -27
  77. package/src/design/template.ts +42 -0
  78. package/src/design/templates/favicon.svg +17 -0
  79. package/src/design/templates/hand-drawn-figure.css +54 -0
  80. package/src/design/templates/preview.html +27 -0
  81. package/src/design/templates/scrollbar.css +29 -0
  82. package/src/design/templates/status.css +24 -0
  83. package/src/gate/embedded.ts +400 -0
  84. package/src/gate/stages.ts +8 -0
  85. package/src/git/files.ts +5 -2
  86. package/src/indexes/frontmatter.ts +27 -6
  87. package/src/indexes/render.ts +21 -3
  88. package/src/markdown/colons.ts +14 -4
  89. package/src/markdown/heading-case.ts +9 -2
  90. package/src/pr/bijection.ts +8 -0
  91. package/src/pr/evidence.ts +81 -13
  92. package/src/pr/frames.ts +1 -6
  93. package/src/pr/health.ts +63 -15
  94. package/src/pr/lift.ts +77 -9
  95. package/src/pr/links.ts +160 -0
  96. package/src/pr/local.ts +17 -4
  97. package/src/pr/paths.ts +20 -2
  98. package/src/pr/rest.ts +2 -0
  99. package/src/pr/review-scope.ts +28 -0
  100. package/src/pr/review-state.ts +5 -5
  101. package/src/pr/waiver.ts +45 -0
  102. package/src/sessions/stop.ts +145 -0
  103. package/src/sessions/transfer/bundle.ts +1 -1
  104. package/src/take/camera.ts +337 -208
  105. package/src/take/encode.ts +16 -42
  106. package/src/take/film.ts +7 -1
  107. package/src/take/frame-clock.ts +46 -0
  108. package/src/take/log.ts +43 -2
  109. package/src/take/perform.ts +85 -13
  110. package/src/take/plan.ts +26 -5
  111. package/src/take/reel.ts +30 -5
  112. package/src/take/screencast.ts +223 -13
  113. package/src/take/stepper.ts +24 -4
  114. package/src/tasks/holders.ts +198 -0
  115. package/src/tasks/validate.ts +148 -0
  116. package/src/commands/fixtures/gh.sh +0 -64
  117. package/src/take/fixtures/busy.html +0 -107
  118. package/src/take/fixtures/fade.html +0 -49
  119. package/src/take/fixtures/fetch.html +0 -38
  120. package/src/take/fixtures/input.html +0 -138
  121. package/src/take/zoom/NOTICE.md +0 -45
  122. package/src/take/zoom/math-utils.ts +0 -69
  123. package/src/take/zoom/region-utils.ts +0 -55
  124. package/src/take/zoom/spring.ts +0 -133
  125. /package/src/sessions/{transfer/transcript.ts → transcript.ts} +0 -0
@@ -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
package/src/git/files.ts CHANGED
@@ -56,15 +56,18 @@ export async function listChangedFiles(
56
56
  root: string,
57
57
  base: string,
58
58
  ): Promise<string[] | undefined> {
59
- const changed = await git(root, ['diff', '--name-only', base])
59
+ // NUL-separated, since newline output C-quotes a path holding a quote or a
60
+ // non-ASCII character and the quoted spelling names no file.
61
+ const changed = await git(root, ['diff', '--name-only', '-z', base])
60
62
  const untracked = await git(root, [
61
63
  'ls-files',
64
+ '-z',
62
65
  '--others',
63
66
  '--exclude-standard',
64
67
  ])
65
68
  if (changed === undefined || untracked === undefined) return undefined
66
69
 
67
- const paths = [...changed.split('\n'), ...untracked.split('\n')]
70
+ const paths = [...changed.split('\0'), ...untracked.split('\0')]
68
71
  return [...new Set(paths.filter(Boolean))].sort()
69
72
  }
70
73
 
@@ -17,22 +17,43 @@ export interface Frontmatter {
17
17
  * missing block instead of ending the caller's whole run.
18
18
  */
19
19
  export function parseFrontmatter(source: string): Frontmatter | undefined {
20
+ const reading = readFrontmatterSource(source)
21
+ return reading.kind === 'parsed' ? reading.frontmatter : undefined
22
+ }
23
+
24
+ export type FrontmatterReading =
25
+ | { readonly kind: 'absent' }
26
+ | { readonly kind: 'invalid'; readonly message: string }
27
+ | { readonly kind: 'parsed'; readonly frontmatter: Frontmatter }
28
+
29
+ /**
30
+ * Tells a source with no block from one whose block failed to parse, which
31
+ * `parseFrontmatter` folds into one undefined. A block that parses to a list
32
+ * or a scalar reads as invalid, since no field can be read off it.
33
+ */
34
+ export function readFrontmatterSource(source: string): FrontmatterReading {
20
35
  const match = source.match(FRONTMATTER)
21
- if (!match) return undefined
36
+ if (!match) return { kind: 'absent' }
22
37
 
23
38
  let parsed: unknown
24
39
  try {
25
40
  parsed = Bun.YAML.parse(match[1])
26
- } catch {
27
- return undefined
41
+ } catch (error) {
42
+ return {
43
+ kind: 'invalid',
44
+ message: error instanceof Error ? error.message : String(error),
45
+ }
28
46
  }
29
47
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
30
- return undefined
48
+ return { kind: 'invalid', message: 'frontmatter is not a mapping' }
31
49
  }
32
50
 
33
51
  return {
34
- raw: match[0],
35
- fields: parsed as Record<string, unknown>,
52
+ kind: 'parsed',
53
+ frontmatter: {
54
+ raw: match[0],
55
+ fields: parsed as Record<string, unknown>,
56
+ },
36
57
  }
37
58
  }
38
59
 
@@ -1,5 +1,9 @@
1
1
  import { basename, dirname, resolve } from 'node:path'
2
- import { readField, readFrontmatter } from '@/indexes/frontmatter'
2
+ import {
3
+ readField,
4
+ readFrontmatter,
5
+ readFrontmatterSource,
6
+ } from '@/indexes/frontmatter'
3
7
  import { INDEX_FILE, listIndexes } from '@/indexes/walk'
4
8
 
5
9
  export interface IndexEntry {
@@ -81,8 +85,19 @@ export async function collectEntries(
81
85
  const entries: IndexEntry[] = []
82
86
  const errors: string[] = []
83
87
 
88
+ let invalid = 0
89
+
84
90
  for (const path of paths) {
85
- const frontmatter = await readFrontmatter(path)
91
+ const reading = readFrontmatterSource(await Bun.file(path).text())
92
+ if (reading.kind === 'invalid') {
93
+ invalid += 1
94
+ errors.push(
95
+ `frontmatter is not valid YAML in ${path}: ${reading.message}`,
96
+ )
97
+ continue
98
+ }
99
+ const frontmatter =
100
+ reading.kind === 'parsed' ? reading.frontmatter : undefined
86
101
  const title = readField(frontmatter, 'title')
87
102
  const description = readField(frontmatter, 'description')
88
103
 
@@ -101,7 +116,10 @@ export async function collectEntries(
101
116
  }
102
117
 
103
118
  if (errors.length > 0) {
104
- errors.push(`${errors.length} missing frontmatter field(s) in ${dir}`)
119
+ const missing = errors.length - invalid
120
+ if (missing > 0) {
121
+ errors.push(`${missing} missing frontmatter field(s) in ${dir}`)
122
+ }
105
123
  return { ok: false, errors }
106
124
  }
107
125
 
@@ -30,6 +30,12 @@ const BOLD_LABEL = /^(\*\*|__)[^*_]+\1$/
30
30
  /** What may stand in a lead once its code spans and links are masked. */
31
31
  const CONNECTIVE = /\b(?:and|or|to)\b|,/g
32
32
 
33
+ /** Words and apostrophes or hyphens between them, with no sentence punctuation. */
34
+ const PLAIN_LEAD = /^[\p{L}\p{N}][\p{L}\p{N}'’ -]*$/u
35
+
36
+ /** The longest plain lead a line may open on and still read as a label. */
37
+ const LEAD_WORDS = 3
38
+
33
39
  /** Blanks a span while holding its width, so a column stays where it was. */
34
40
  function blank(match: string): string {
35
41
  return ' '.repeat(match.length)
@@ -38,16 +44,20 @@ function blank(match: string): string {
38
44
  /**
39
45
  * Whether the text before a line's first colon is a label rather than a
40
46
  * clause: the lead of a list item, or a prefix that is only emphasis and a
41
- * masked code span. A list item's lead also reads as a label at any length
42
- * when only code spans, links, commas, and `and`, `or`, or `to` stand in it,
43
- * which is a file entry naming several paths.
47
+ * masked code span. A line, after any quote marker, also reads as a label when
48
+ * its lead is at most three plain words, such as `Issue` or `Test strategy`.
49
+ * A list item's lead also reads as a label at any length when only code spans,
50
+ * links, commas, and `and`, `or`, or `to` stand in it, which is a file entry
51
+ * naming several paths.
44
52
  */
45
53
  function isLabel(prefix: string, listItem: boolean): boolean {
46
54
  if (BOLD_LABEL.test(prefix.trim())) return true
47
55
 
48
56
  const words = prefix.replace(/[*_]/g, ' ').trim()
49
57
  if (words === '') return true
50
- if (!listItem) return false
58
+ if (!listItem) {
59
+ return PLAIN_LEAD.test(words) && words.split(/\s+/).length <= LEAD_WORDS
60
+ }
51
61
  if (words.replace(CONNECTIVE, ' ').trim() === '') return true
52
62
  return words.split(/\s+/).length <= 4
53
63
  }
@@ -14,15 +14,22 @@ const TITLE = /^title:\s*(["']?)(.*?)\1\s*$/
14
14
  const CODE_SPAN = /(`+)(?:(?!\1).)*\1/g
15
15
  const CAPITALIZED = /^[A-Z][a-z]/
16
16
 
17
+ /** A task's phase label, `v` and dotted digits, which the template fixes. */
18
+ const PHASE_LABEL = /^v\d+\.\d+:\s+/
19
+
17
20
  /**
18
21
  * The words of `text` that break sentence case.
19
22
  *
20
23
  * A code span stands for one lowercase word, so a heading opening on one
21
24
  * counts that span as its first word. The first word with a letter in it is
22
- * exempt, which keeps a leading numeral from promoting the next word.
25
+ * exempt, which keeps a leading numeral from promoting the next word. A
26
+ * leading phase label is not a word of the heading, so the first word is the
27
+ * one after it.
23
28
  */
24
29
  function capitalizedAfterFirst(text: string): string[] {
25
- const masked = maskDisplayed(text.replace(CODE_SPAN, 'code'))
30
+ const masked = maskDisplayed(
31
+ text.replace(PHASE_LABEL, '').replace(CODE_SPAN, 'code'),
32
+ )
26
33
  .replace(/\s+/g, ' ')
27
34
  .trim()
28
35
  const words = masked === '' ? [] : masked.split(' ')