@erclx/canon 5.76.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.
- package/CHANGELOG.md +33 -0
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/auto-ship/references/ui-checklist.md +9 -1
- package/claude/skills/ci-workflow/REQUIREMENT.md +8 -2
- package/claude/skills/ci-workflow/SKILL.md +3 -0
- 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/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-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/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/SKILL.md +2 -2
- 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-worker/SKILL.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/agents/targets.md +3 -1
- package/docs/workflow/operating-model.md +6 -5
- 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 +5 -4
- package/src/commands/pr.ts +117 -31
- package/src/commands/take.ts +12 -1
- package/src/commands/targets.ts +11 -1
- 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/pr/evidence.ts +43 -4
- package/src/pr/frames.ts +1 -6
- package/src/pr/lift.ts +77 -9
- 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/take/camera.ts +337 -208
- package/src/take/film.ts +7 -1
- 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 +110 -3
- package/src/take/stepper.ts +24 -4
- package/src/targets/registry.ts +99 -14
- package/src/targets/resolve.ts +3 -1
- package/tooling/astro/configs/card.config.mjs +4 -0
- package/tooling/astro/configs/scripts/check-card-exclusion.sh +7 -2
- package/tooling/astro/reference.md +2 -1
- package/tooling/web/configs/.github/workflows/readme-screenshot.yml +3 -1
- package/tooling/web/configs/.github/workflows/verify.yml +5 -2
- package/tooling/web/configs/scripts/install-playwright-deps.sh +52 -0
- package/tooling/web/reference.md +5 -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
|
@@ -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
|
+
}
|
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
|