@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.
- package/CHANGELOG.md +43 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/REQUIREMENT.md +12 -0
- package/claude/skills/app-drive/SKILL.md +44 -8
- package/claude/skills/app-drive/references/config.md +18 -8
- package/claude/skills/app-drive/references/drive-record.md +4 -0
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +5 -4
- package/claude/skills/auto-ship/references/draft-mark.md +33 -15
- package/claude/skills/auto-ship/references/failure-recovery.md +4 -2
- package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +7 -0
- package/claude/skills/code-craft/references/adopted.md +8 -0
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/final-command.md +34 -2
- package/claude/skills/git-pr/references/pr.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +2 -2
- package/claude/skills/git-ship/SKILL.md +16 -11
- package/claude/skills/git-ship/references/ci-watch.md +28 -4
- package/claude/skills/markdown-craft/references/markdown.md +4 -4
- package/claude/skills/plan-feature/references/plan.md +2 -0
- package/claude/skills/pr-evidence/SKILL.md +6 -1
- package/claude/skills/pr-evidence/references/evidence.md +1 -0
- package/claude/skills/pr-evidence/references/reel.md +9 -1
- package/claude/skills/review-address/SKILL.md +2 -1
- package/claude/skills/review-craft/references/ui.md +13 -1
- package/claude/skills/review-pr/references/body-variants.md +2 -0
- package/claude/skills/review-pr/references/close-out.md +2 -0
- package/claude/skills/review-pr/references/key-changes.md +2 -1
- package/claude/skills/review-pr/references/post.md +1 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -0
- package/claude/skills/review-ui/SKILL.md +2 -2
- package/claude/skills/review-ui/references/driving.md +32 -4
- package/claude/skills/review-ui/references/post.md +6 -2
- package/claude/skills/role-orchestrator/REQUIREMENT.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-hand-launch.md +2 -0
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-limit-stops.md +47 -0
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +24 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +3 -2
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +35 -4
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +3 -1
- package/claude/skills/role-worker/SKILL.md +2 -1
- package/claude/skills/task-board/SKILL.md +1 -0
- package/claude/skills/task-board/references/tasks.md +1 -0
- package/claude/skills/test-craft/SKILL.md +1 -0
- package/claude/skills/ui-checklist/REQUIREMENT.md +1 -1
- package/claude/skills/ui-checklist/SKILL.md +2 -1
- package/claude/skills/ui-craft/references/capture.md +16 -0
- package/claude/skills/ui-film/REQUIREMENT.md +2 -2
- package/claude/skills/ui-film/SKILL.md +6 -1
- package/claude/skills/ui-film/references/compose-craft.md +68 -11
- package/claude/skills/ui-film/references/config.md +2 -2
- package/claude/skills/ui-film/references/handoff.md +1 -1
- package/claude/skills/ui-film/references/harness.md +71 -0
- package/claude/skills/ui-film/references/take-craft.md +42 -17
- package/docs/workflow/ai-workflow.md +1 -1
- package/docs/workflow/operating-model.md +6 -5
- package/docs/workflow/parallel-features.md +2 -1
- package/docs/workflow/visual-design-workflow.md +2 -1
- package/governance/ledger/skills/code-craft/SKILL.md +9 -0
- package/governance/ledger/skills/test-craft/SKILL.md +1 -0
- package/governance/ledger/skills/ui-craft/capture.md +18 -5
- package/package.json +4 -3
- package/src/commands/pr.ts +255 -46
- package/src/commands/sessions.ts +88 -6
- package/src/commands/take.ts +12 -1
- package/src/commands/tasks.ts +41 -2
- package/src/design/components.ts +5 -107
- package/src/design/favicon.ts +21 -17
- package/src/design/render.ts +9 -27
- package/src/design/template.ts +42 -0
- package/src/design/templates/favicon.svg +17 -0
- package/src/design/templates/hand-drawn-figure.css +54 -0
- package/src/design/templates/preview.html +27 -0
- package/src/design/templates/scrollbar.css +29 -0
- package/src/design/templates/status.css +24 -0
- package/src/gate/embedded.ts +400 -0
- package/src/gate/stages.ts +8 -0
- package/src/git/files.ts +5 -2
- package/src/indexes/frontmatter.ts +27 -6
- package/src/indexes/render.ts +21 -3
- package/src/markdown/colons.ts +14 -4
- package/src/markdown/heading-case.ts +9 -2
- package/src/pr/bijection.ts +8 -0
- package/src/pr/evidence.ts +81 -13
- package/src/pr/frames.ts +1 -6
- package/src/pr/health.ts +63 -15
- package/src/pr/lift.ts +77 -9
- package/src/pr/links.ts +160 -0
- package/src/pr/local.ts +17 -4
- package/src/pr/paths.ts +20 -2
- package/src/pr/rest.ts +2 -0
- package/src/pr/review-scope.ts +28 -0
- package/src/pr/review-state.ts +5 -5
- package/src/pr/waiver.ts +45 -0
- package/src/sessions/stop.ts +145 -0
- package/src/sessions/transfer/bundle.ts +1 -1
- package/src/take/camera.ts +337 -208
- package/src/take/encode.ts +16 -42
- package/src/take/film.ts +7 -1
- package/src/take/frame-clock.ts +46 -0
- package/src/take/log.ts +43 -2
- package/src/take/perform.ts +85 -13
- package/src/take/plan.ts +26 -5
- package/src/take/reel.ts +30 -5
- package/src/take/screencast.ts +223 -13
- package/src/take/stepper.ts +24 -4
- package/src/tasks/holders.ts +198 -0
- package/src/tasks/validate.ts +148 -0
- package/src/commands/fixtures/gh.sh +0 -64
- package/src/take/fixtures/busy.html +0 -107
- package/src/take/fixtures/fade.html +0 -49
- package/src/take/fixtures/fetch.html +0 -38
- package/src/take/fixtures/input.html +0 -138
- package/src/take/zoom/NOTICE.md +0 -45
- package/src/take/zoom/math-utils.ts +0 -69
- package/src/take/zoom/region-utils.ts +0 -55
- package/src/take/zoom/spring.ts +0 -133
- /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
|
+
}
|
package/src/gate/stages.ts
CHANGED
|
@@ -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
|
-
|
|
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('\
|
|
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
|
|
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
|
|
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
|
|
48
|
+
return { kind: 'invalid', message: 'frontmatter is not a mapping' }
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
return {
|
|
34
|
-
|
|
35
|
-
|
|
52
|
+
kind: 'parsed',
|
|
53
|
+
frontmatter: {
|
|
54
|
+
raw: match[0],
|
|
55
|
+
fields: parsed as Record<string, unknown>,
|
|
56
|
+
},
|
|
36
57
|
}
|
|
37
58
|
}
|
|
38
59
|
|
package/src/indexes/render.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { basename, dirname, resolve } from 'node:path'
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/markdown/colons.ts
CHANGED
|
@@ -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
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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)
|
|
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(
|
|
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(' ')
|