@ultimat3/cli 19.4.0 → 20.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ // The `image-dimensions` guard `x new` ships: an image reserves its box before its bytes arrive.
2
+ // An unsized `<img>` is the largest single contributor to Cumulative Layout Shift — the page reflows
3
+ // under the reader's finger the moment the bytes land — and the second half is its mirror image: a
4
+ // `loading="lazy"` on the image the page is judged by is a deliberate delay on the one paint the
5
+ // metric measures. Both are decidable from the attributes and nothing in the gate could see either.
6
+
7
+ import { guardCode } from './guard';
8
+ import type { GeneratedFile } from './naming';
9
+
10
+ /**
11
+ * Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
12
+ * An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
13
+ * registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
14
+ * own conventions raise, so this one is spelled by the file it lands in and nowhere else.
15
+ */
16
+ const NAME = 'image-dimensions';
17
+ const CODE = guardCode(NAME);
18
+
19
+ const source =
20
+ (): string => `// image-dimensions: an image reserves its box before its bytes arrive.
21
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
22
+ // step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
23
+
24
+ import type { Finding, Guard } from '@ultimat3/cli';
25
+
26
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
27
+ const CODE = '${CODE}';
28
+
29
+ /** The raw element and the framework's wrapper. A background-image reserves nothing and is not one. */
30
+ const IMAGES = new Set(['img', 'Image']);
31
+
32
+ /** \`(?<![\\w-])\` on every one: \`maxWidth\` is not \`width\`, and \`data-loading\` is not \`loading\`. */
33
+ const WIDTH = /(?<![\\w-])width\\s*=/;
34
+ const HEIGHT = /(?<![\\w-])height\\s*=/;
35
+ const ASPECT = /aspect-ratio/i;
36
+ const LAZY = /(?<![\\w-])loading\\s*=\\s*['"{]?\\s*['"]?lazy/i;
37
+ /** A bare JSX boolean (\`priority\`), \`priority={true}\`, or the HTML attribute spelled either way. */
38
+ const PRIORITY = /(?<![\\w-])(priority|fetch[Pp]riority\\s*=\\s*['"{]?\\s*['"]?high)/;
39
+
40
+ export interface SourceFile {
41
+ /** App-root-relative POSIX path, so the finding names the file an author opens. */
42
+ readonly path: string;
43
+ readonly source: string;
44
+ }
45
+
46
+ interface Tag {
47
+ readonly name: string;
48
+ readonly attrs: string;
49
+ readonly index: number;
50
+ }
51
+
52
+ /** Comments blanked IN PLACE — not deleted — so a reported line number still points at the source. */
53
+ const blank = (text: string): string =>
54
+ text
55
+ .replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
56
+ .replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
57
+
58
+ const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
59
+
60
+ const NAME_AT = /^([A-Za-z][\\w.-]*)/;
61
+
62
+ /**
63
+ * Every opening tag, with its attribute text. The tag ends at the first \`>\` OUTSIDE braces and
64
+ * quotes: \`srcset={widths.map((w) => …)}\` holds a \`>\` that closes nothing, so a pattern reading to
65
+ * the next \`>\` would cut the element in half and read half an attribute list as the whole one.
66
+ *
67
+ * The scanner is spelled out again here rather than shared, and that is the mechanism's doing:
68
+ * every file in \`guards/\` is a guard, so a helper module beside this one would be discovered and
69
+ * refused as a guard with no rule (\`X_GUARD_INVALID\`). One file per rule, deletable on its own.
70
+ */
71
+ function openingTags(text: string): readonly Tag[] {
72
+ const tags: Tag[] = [];
73
+ for (let i = 0; i < text.length; i += 1) {
74
+ if (text[i] !== '<') continue;
75
+ const name = NAME_AT.exec(text.slice(i + 1, i + 64))?.[1];
76
+ if (name === undefined) continue;
77
+ const from = i + 1 + name.length;
78
+ let depth = 0;
79
+ let quote = '';
80
+ let end = from;
81
+ for (; end < text.length; end += 1) {
82
+ const ch = text[end];
83
+ if (quote !== '') {
84
+ if (ch === quote) quote = '';
85
+ continue;
86
+ }
87
+ if (ch === '"' || ch === "'" || ch === '\`') quote = ch;
88
+ else if (ch === '{') depth += 1;
89
+ else if (ch === '}') depth -= 1;
90
+ else if (depth === 0 && (ch === '>' || ch === '<')) break;
91
+ }
92
+ tags.push({ name, attrs: text.slice(from, end), index: i });
93
+ i = from - 1;
94
+ }
95
+ return tags;
96
+ }
97
+
98
+ /**
99
+ * \`<template>…</template>\` holds markup the browser never lays out, so nothing inside one can
100
+ * shift anything. Reported, it would be a finding an author cannot act on.
101
+ */
102
+ function templateRanges(text: string): readonly (readonly [number, number])[] {
103
+ const ranges: (readonly [number, number])[] = [];
104
+ for (const match of text.matchAll(/<template\\b[\\s\\S]*?<\\/template>/gi)) {
105
+ ranges.push([match.index, match.index + match[0].length]);
106
+ }
107
+ return ranges;
108
+ }
109
+
110
+ /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
111
+ export function unsizedImages(files: readonly SourceFile[]): readonly Finding[] {
112
+ const findings: Finding[] = [];
113
+ for (const file of files) {
114
+ const text = blank(file.source);
115
+ const inert = templateRanges(text);
116
+ for (const tag of openingTags(text)) {
117
+ if (!IMAGES.has(tag.name)) continue;
118
+ if (inert.some(([from, to]) => tag.index > from && tag.index < to)) continue;
119
+ const at = \`\${file.path}:\${lineOf(text, tag.index)}\`;
120
+ if (!ASPECT.test(tag.attrs) && !(WIDTH.test(tag.attrs) && HEIGHT.test(tag.attrs))) {
121
+ findings.push({
122
+ code: CODE,
123
+ cause: \`\${at} renders <\${tag.name}> with no width and height pair and no aspect-ratio — the browser reserves no box for it, so every element under it jumps the moment the bytes land\`,
124
+ fix: \`add width and height to the <\${tag.name}> at \${at} — the intrinsic pixel size, since CSS still decides what it is drawn at — or an aspect-ratio, then: x verify\`,
125
+ at: file.path,
126
+ });
127
+ }
128
+ // A second finding on the same tag, deliberately: it is a different mistake with a different
129
+ // edit, and folding the two would hand the reader one of the repairs it needs.
130
+ if (!LAZY.test(tag.attrs) || !PRIORITY.test(tag.attrs)) continue;
131
+ findings.push({
132
+ code: CODE,
133
+ cause: \`\${at} marks <\${tag.name}> as the priority image AND loading="lazy" — a lazy image is fetched after layout, and this is the one the page's largest paint is measured on, so the attribute delays the metric it is the subject of\`,
134
+ fix: \`delete loading="lazy" from the <\${tag.name}> at \${at}, then: x verify\`,
135
+ at: file.path,
136
+ });
137
+ }
138
+ }
139
+ return findings;
140
+ }
141
+
142
+ export const guard: Guard = {
143
+ summary: 'an image declares its box, and the priority one is never lazy',
144
+ async check(root) {
145
+ const files: SourceFile[] = [];
146
+ // TWO globs, and the rule is that a brace ALTERNATIVE may not contain a \`/\`. Measured on Bun
147
+ // 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
148
+ // \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
149
+ // matches 17 — so folding these into one line silently turns the guard off, which is worse than
150
+ // the hole it closes. A LEADING group is fine and four guards here rely on it:
151
+ // \`{apps,packages}/**/*.scss\` matches all 15.
152
+ for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
153
+ for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
154
+ const path = entry.split('\\\\').join('/');
155
+ if (path.includes('node_modules/') || /\\.test\\.tsx?$/.test(path)) continue;
156
+ files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
157
+ }
158
+ }
159
+ return unsizedImages(files);
160
+ },
161
+ };
162
+ `;
163
+
164
+ const test =
165
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
166
+ // a green gate over the convention it was written to enforce.
167
+
168
+ import { expect, unitTest } from '@ultimat3/testing';
169
+ import { unsizedImages } from './image-dimensions';
170
+
171
+ const file = (source: string) => [{ path: 'apps/web/site/page.tsx', source }];
172
+
173
+ unitTest('an img with no dimensions is refused, and the finding names the line', () => {
174
+ const findings = unsizedImages(file('<main>\\n <img src="/hero.png" alt="" />\\n</main>'));
175
+ expect(findings).toHaveLength(1);
176
+ expect(findings[0]?.code).toBe('${CODE}');
177
+ expect(findings[0]?.cause).toContain(':2');
178
+ expect(findings[0]?.fix).toContain('aspect-ratio');
179
+ });
180
+
181
+ unitTest('a width without a height is half a box, and half is none', () => {
182
+ expect(unsizedImages(file('<img src="/a.png" width={800} alt="" />'))).toHaveLength(1);
183
+ });
184
+
185
+ unitTest('the pair satisfies it, and so does an aspect-ratio on its own', () => {
186
+ expect(unsizedImages(file('<img src="/a.png" width={800} height={600} alt="" />'))).toEqual([]);
187
+ const styled = '<img src="/a.png" style={{ "aspect-ratio": "16 / 9" }} alt="" />';
188
+ expect(unsizedImages(file(styled))).toEqual([]);
189
+ });
190
+
191
+ unitTest('the framework wrapper is the same element for this purpose', () => {
192
+ expect(unsizedImages(file('<Image src="/a.png" alt="" />'))).toHaveLength(1);
193
+ });
194
+
195
+ unitTest('a lazy priority image is its own finding, with its own edit', () => {
196
+ const source = '<img src="/hero.png" width={800} height={600} priority loading="lazy" alt="" />';
197
+ const findings = unsizedImages(file(source));
198
+ expect(findings).toHaveLength(1);
199
+ expect(findings[0]?.fix).toContain('delete loading="lazy"');
200
+ });
201
+
202
+ unitTest('lazy without priority is the right thing to write', () => {
203
+ const source = '<img src="/thumb.png" width={80} height={80} loading="lazy" alt="" />';
204
+ expect(unsizedImages(file(source))).toEqual([]);
205
+ });
206
+
207
+ // Nothing inside a <template> is laid out, so nothing inside one can shift anything.
208
+ unitTest('an img inside a template shifts nothing and is not reported', () => {
209
+ const source = '<template><img src="/a.png" alt="" /></template>';
210
+ expect(unsizedImages(file(source))).toEqual([]);
211
+ });
212
+
213
+ unitTest('maxWidth is not width, and a commented-out img is not an element', () => {
214
+ expect(unsizedImages(file('<img src="/a.png" maxWidth={8} height={6} alt="" />'))).toHaveLength(
215
+ 1,
216
+ );
217
+ expect(unsizedImages(file('// <img src="/a.png" alt="" />\\nconst a = 1;'))).toEqual([]);
218
+ });
219
+ `;
220
+
221
+ /** `guards/image-dimensions.ts` and its test. The directory is the registration. */
222
+ export const imageDimensionsGuardFiles = (): readonly GeneratedFile[] => [
223
+ { path: 'guards/image-dimensions.ts', contents: source() },
224
+ { path: 'guards/image-dimensions.test.ts', contents: test() },
225
+ ];
@@ -0,0 +1,128 @@
1
+ // The `island-without-states` guard `x new` ships, and it closes a loop the framework already built
2
+ // half of. `x shot --island <name>` photographs an island in every state its sibling
3
+ // `<name>.island.states.ts` declares — including the ones nobody can reach by clicking: a save the
4
+ // server refused, a read that came back empty, a label three times as long in the next locale. An
5
+ // island with no states file has never been seen in any of them, by a reviewer or by a model.
6
+
7
+ import { guardCode } from './guard';
8
+ import type { GeneratedFile } from './naming';
9
+
10
+ /**
11
+ * Derived from the guard's name, never written as a literal — the same rule `x g guard` follows.
12
+ * An `X_*` literal in framework source is a FRAMEWORK code: `error-catalog.test.ts` refuses one the
13
+ * registry does not hold, and `wiki/Error-Codes.md` would owe it a row. The APP owns the codes its
14
+ * own conventions raise, so this one is spelled by the file it lands in and nowhere else.
15
+ */
16
+ const NAME = 'island-without-states';
17
+ const CODE = guardCode(NAME);
18
+
19
+ const source =
20
+ (): string => `// island-without-states: every island declares the states it can be photographed in.
21
+ // \`x verify\` discovers every file in \`guards/\` and runs its \`guard\` inside the \`boundaries\`
22
+ // step — nothing registers this file, so nothing can forget to. Delete it to drop the rule.
23
+
24
+ import type { Finding, Guard } from '@ultimat3/cli';
25
+
26
+ /** The app owns the codes its own conventions raise — this one is named for the guard. */
27
+ const CODE = '${CODE}';
28
+
29
+ const ISLAND_SUFFIX = '.island.tsx';
30
+ const STATES_SUFFIX = '.island.states.ts';
31
+
32
+ /** The one file that answers for an island, derived rather than searched for. */
33
+ export const statesPathFor = (island: string): string =>
34
+ \`\${island.slice(0, -ISLAND_SUFFIX.length)}\${STATES_SUFFIX}\`;
35
+
36
+ /** \`apps/web/app/post/post-form.island.tsx\` → \`post-form\`: what \`x shot --island\` is given. */
37
+ const islandName = (island: string): string => {
38
+ const base = island.split('/').pop() ?? island;
39
+ return (base.split('.')[0] ?? base).toLowerCase();
40
+ };
41
+
42
+ /**
43
+ * Pure — the caller does the I/O — so the rule is testable without a filesystem. Both lists come
44
+ * out of one directory walk, which is what keeps the answer to "is there a states file" a set
45
+ * lookup rather than a second stat per island.
46
+ */
47
+ export function islandsWithoutStates(
48
+ islands: readonly string[],
49
+ states: readonly string[],
50
+ ): readonly Finding[] {
51
+ const declared = new Set(states);
52
+ const findings: Finding[] = [];
53
+ for (const island of islands) {
54
+ const path = statesPathFor(island);
55
+ if (declared.has(path)) continue;
56
+ findings.push({
57
+ code: CODE,
58
+ cause: \`\${island} declares no states — x shot --island photographs an island in every state its states file names, so nothing has ever seen this component with a refused save, an empty read or a translated label three times as long as the one it was written against\`,
59
+ fix: \`write \${path} — defineIslandStates({ island: '\${island}', states: [{ id: 'idle', title: 'the first paint', props: {} }] }) — then: x shot --island \${islandName(island)} --json\`,
60
+ at: island,
61
+ });
62
+ }
63
+ return findings;
64
+ }
65
+
66
+ export const guard: Guard = {
67
+ summary: 'an island declares the states it can be photographed in',
68
+ async check(root) {
69
+ const islands: string[] = [];
70
+ const states: string[] = [];
71
+ // Two patterns, one per root. These two CAN fold — \`{apps,packages}/**/*.island.*\` matches the
72
+ // same 12 files on Bun 1.4.0, because a LEADING brace group is fine. What does not work is a
73
+ // brace ALTERNATIVE containing a \`/\`: \`{apps/web,packages/ui}/**/*.tsx\` matches ZERO files in
74
+ // \`examples/dummy\` where \`apps/*/{site,app}/**/*.tsx\` matches 17, which is why the guards
75
+ // scanning \`site\`/\`app\` keep the loop — and this one keeps its shape to match them.
76
+ for (const pattern of ['apps/**/*.island.*', 'packages/**/*.island.*']) {
77
+ for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
78
+ const path = entry.split('\\\\').join('/');
79
+ if (path.includes('node_modules/') || path.includes('/dist/')) continue;
80
+ if (path.endsWith(ISLAND_SUFFIX)) islands.push(path);
81
+ else if (path.endsWith(STATES_SUFFIX)) states.push(path);
82
+ }
83
+ }
84
+ return islandsWithoutStates(islands.sort(), states);
85
+ },
86
+ };
87
+ `;
88
+
89
+ const test =
90
+ (): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
91
+ // a green gate over the convention it was written to enforce.
92
+
93
+ import { expect, unitTest } from '@ultimat3/testing';
94
+ import { islandsWithoutStates, statesPathFor } from './island-without-states';
95
+
96
+ const ISLAND = 'apps/web/app/post/post-form.island.tsx';
97
+
98
+ unitTest('an island with no states file is refused, and the fix names the file to write', () => {
99
+ const findings = islandsWithoutStates([ISLAND], []);
100
+ expect(findings).toHaveLength(1);
101
+ expect(findings[0]?.code).toBe('${CODE}');
102
+ expect(findings[0]?.at).toBe(ISLAND);
103
+ expect(findings[0]?.fix).toContain('apps/web/app/post/post-form.island.states.ts');
104
+ // The name \`x shot --island\` is given, not the path: a fix is pasted and run verbatim.
105
+ expect(findings[0]?.fix).toContain('x shot --island post-form');
106
+ });
107
+
108
+ unitTest('the sibling states file satisfies it', () => {
109
+ expect(islandsWithoutStates([ISLAND], [statesPathFor(ISLAND)])).toEqual([]);
110
+ });
111
+
112
+ // The states file is found by DERIVING its path, never by name: a states file for another island
113
+ // in the same directory answers for that island and not for this one.
114
+ unitTest('a states file for a different island in the same folder is not this one', () => {
115
+ const other = 'apps/web/app/post/comment-box.island.states.ts';
116
+ expect(islandsWithoutStates([ISLAND], [other])).toHaveLength(1);
117
+ });
118
+
119
+ unitTest('the derived path swaps the island suffix and nothing else', () => {
120
+ expect(statesPathFor(ISLAND)).toBe('apps/web/app/post/post-form.island.states.ts');
121
+ });
122
+ `;
123
+
124
+ /** `guards/island-without-states.ts` and its test. The directory is the registration. */
125
+ export const islandWithoutStatesGuardFiles = (): readonly GeneratedFile[] => [
126
+ { path: 'guards/island-without-states.ts', contents: source() },
127
+ { path: 'guards/island-without-states.test.ts', contents: test() },
128
+ ];
@@ -27,16 +27,24 @@ const CODE = '${CODE}';
27
27
 
28
28
  /** A hex literal. \`#{$x}\` is Sass interpolation, not a colour, and \`{\` is not a hex digit. */
29
29
  const HEX = /#[0-9a-fA-F]{3,8}\\b/;
30
- const CHANNEL_FUNCTION = /\\b(?:rgba?|hsla?|lab|lch|oklab|oklch|color)\\(/i;
30
+ /**
31
+ * A channel function OPENS a colour; it does not make one raw. \`rgb(var(--color-bg) / 1)\` is what
32
+ * a semantic token compiles to, so the argument list decides — see \`literalChannelCall\`.
33
+ * \`color-mix(\` is not one of these: \`color\` is followed by \`-\`, never \`(\`.
34
+ */
35
+ const CHANNEL_FUNCTION = /\\b(rgba?|hsla?|lab|lch|oklab|oklch|color)\\(/gi;
31
36
  /** The named colours a human actually types. The full CSS list would report \`.item\` selectors. */
32
37
  const NAMED =
33
38
  /\\b(?:white|black|red|green|blue|yellow|orange|purple|pink|brown|gray|grey|silver|navy|teal|olive|lime|aqua|maroon|fuchsia|gold|beige|coral|crimson|indigo|violet|khaki|salmon|tan|turquoise|wheat)\\b/i;
34
39
 
35
40
  /**
36
41
  * A DECLARATION, never a whole line: a selector carries no colon, so \`#hero { … }\` is not a value
37
- * and is never reported. The value stops at the first \`;\`, \`{\` or \`}\`.
42
+ * and is never reported. The value stops at the first \`;\`, \`{\` or \`}\` — except a Sass \`#{…}\`
43
+ * interpolation, which is a VALUE carrying braces. Without that alternative,
44
+ * \`color: rgb(var(--color-fg) / #{\\$alpha});\` — what \`@ultimat3/ui\`'s own \`role()\` emits — reads as
45
+ * the truncated \`rgb(var(--color-fg) / #\`, so the rule skips it instead of deciding it.
38
46
  */
39
- const DECLARATION = /([\\w-]+)\\s*:\\s*([^;{}]+)/g;
47
+ const DECLARATION = /([\\w-]+)\\s*:\\s*((?:#\\{[^}]*\\}|[^;{}])+)/g;
40
48
 
41
49
  export interface StyleFile {
42
50
  /** App-root-relative POSIX path, so the finding names the file an author opens. */
@@ -58,6 +66,70 @@ const unquote = (value: string): string => value.replaceAll(/'[^']*'|"[^"]*"/g,
58
66
 
59
67
  const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
60
68
 
69
+ /**
70
+ * The BALANCED argument list of the call whose \`(\` sits at \`open\`. \`var(--x)\` nests, so reading
71
+ * to the first \`)\` cuts \`rgb(var(--color-bg) / 1)\` in half and every rule below reads the wrong
72
+ * text. \`undefined\` when the call is never closed — a value the declaration scan truncated.
73
+ */
74
+ const argumentsAt = (value: string, open: number): string | undefined => {
75
+ let depth = 0;
76
+ for (let index = open; index < value.length; index += 1) {
77
+ const character = value.charAt(index);
78
+ if (character === '(') depth += 1;
79
+ else if (character === ')') {
80
+ depth -= 1;
81
+ if (depth === 0) return value.slice(open + 1, index);
82
+ }
83
+ }
84
+ return undefined;
85
+ };
86
+
87
+ /**
88
+ * Every \`var(…)\` reference and every Sass \`#{…}\` interpolation removed — a slot a theme restates,
89
+ * which is the whole point of a token. What is LEFT is what the author wrote by hand.
90
+ */
91
+ const maskReferences = (args: string): string => {
92
+ let out = '';
93
+ let index = 0;
94
+ while (index < args.length) {
95
+ if (args.startsWith('#{', index)) {
96
+ const end = args.indexOf('}', index);
97
+ if (end === -1) break;
98
+ index = end + 1;
99
+ continue;
100
+ }
101
+ if (args.startsWith('var(', index)) {
102
+ const group = argumentsAt(args, index + 3);
103
+ if (group === undefined) break;
104
+ index += group.length + 5;
105
+ continue;
106
+ }
107
+ out += args.charAt(index);
108
+ index += 1;
109
+ }
110
+ return out + args.slice(index);
111
+ };
112
+
113
+ /**
114
+ * What may remain once the references are gone: separators, and — introduced by \`/\` or \`,\` and
115
+ * LAST — one numeric alpha, because \`rgb(var(--color-fg) / 0.5)\` and the legacy
116
+ * \`rgba(var(--color-fg), 0.5)\` are both the token form. The number may not be removed in general:
117
+ * \`rgb(var(--x) 2 3)\` is two hand-written channels wearing one reference, and stays reported.
118
+ * The optional leading identifier is \`color()\`'s colourspace — \`color(display-p3 var(--r) …)\`.
119
+ */
120
+ const RESTATABLE = /^[\\s,/]*(?:[a-z][a-z0-9-]*\\s+)?[\\s,/]*(?:[/,]\\s*\\.?\\d+(?:\\.\\d+)?%?)?\\s*$/i;
121
+
122
+ /** The first channel function written with a literal channel, rendered whole for the finding. */
123
+ const literalChannelCall = (value: string): string | undefined => {
124
+ for (const match of value.matchAll(CHANNEL_FUNCTION)) {
125
+ const args = argumentsAt(value, match.index + match[0].length - 1);
126
+ if (args === undefined) continue;
127
+ if (RESTATABLE.test(maskReferences(args))) continue;
128
+ return \`\${match[1] ?? ''}(\${args})\`;
129
+ }
130
+ return undefined;
131
+ };
132
+
61
133
  /** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
62
134
  export function rawColours(files: readonly StyleFile[]): readonly Finding[] {
63
135
  const findings: Finding[] = [];
@@ -66,12 +138,12 @@ export function rawColours(files: readonly StyleFile[]): readonly Finding[] {
66
138
  for (const match of scss.matchAll(DECLARATION)) {
67
139
  const property = match[1] ?? '';
68
140
  const value = unquote(match[2] ?? '');
69
- const literal = HEX.exec(value) ?? CHANNEL_FUNCTION.exec(value) ?? NAMED.exec(value);
70
- if (literal === null) continue;
141
+ const literal = HEX.exec(value)?.[0] ?? literalChannelCall(value) ?? NAMED.exec(value)?.[0];
142
+ if (literal === undefined) continue;
71
143
  findings.push({
72
144
  code: CODE,
73
- cause: \`\${file.path}:\${lineOf(scss, match.index)} sets \${property} to the raw colour \${literal[0]} — a value no theme can restate, so dark theme renders it unchanged\`,
74
- fix: \`replace \${literal[0]} in \${file.path} with tokens.role('fg'), tokens.role('bg') or the role this element means, then: x verify\`,
145
+ cause: \`\${file.path}:\${lineOf(scss, match.index)} sets \${property} to the raw colour \${literal} — a value no theme can restate, so dark theme renders it unchanged\`,
146
+ fix: \`replace \${literal} in \${file.path} with tokens.role('fg'), tokens.role('bg') or the role this element means, then: x verify\`,
75
147
  at: file.path,
76
148
  });
77
149
  }
@@ -114,11 +186,43 @@ unitTest('a hex literal in a declaration is refused', () => {
114
186
  });
115
187
 
116
188
  unitTest('rgb(), hsl() and a named colour are the same rule', () => {
117
- expect(rawColours(sheet('.a { background: rgb(1 2 3); }'))).toHaveLength(1);
189
+ const channels = rawColours(sheet('.a { background: rgb(1 2 3); }'));
190
+ expect(channels).toHaveLength(1);
191
+ // The whole call, never the bare \`rgb(\` — a fix line telling an author to replace \`rgb(\` names
192
+ // nothing they can find in the file.
193
+ expect(channels[0]?.cause).toContain('rgb(1 2 3)');
118
194
  expect(rawColours(sheet('.a { background: hsl(1 2% 3%); }'))).toHaveLength(1);
119
195
  expect(rawColours(sheet('.a { border-color: white; }'))).toHaveLength(1);
120
196
  });
121
197
 
198
+ // The legitimate lookalike, and the one this rule got wrong: a channel function OVER TOKENS is the
199
+ // token form. \`tokens.role('bg')\` compiles to \`rgb(var(--color-bg) / 1)\`, so reading \`rgb(\` as a
200
+ // raw colour reports the idiom the rule exists to require — and a rule that noisy gets deleted.
201
+ unitTest('a channel function over var(--…) references is the token form', () => {
202
+ expect(rawColours(sheet('.a { color: rgb(var(--color-fg) / 1); }'))).toEqual([]);
203
+ expect(rawColours(sheet('.a { background-color: rgb(var(--color-bg-soft)); }'))).toEqual([]);
204
+ expect(rawColours(sheet('.a { border: 1px solid rgb(var(--color-line)); }'))).toEqual([]);
205
+ expect(rawColours(sheet('.a { outline-color: rgba(var(--color-accent), 0.5); }'))).toEqual([]);
206
+ expect(rawColours(sheet('.a { color: color(display-p3 var(--r) var(--g) var(--b)); }'))).toEqual(
207
+ [],
208
+ );
209
+ });
210
+
211
+ // The other direction: one reference does not launder the literals beside it, or every raw colour
212
+ // gains a one-token disguise.
213
+ unitTest('a literal channel beside a reference is still refused', () => {
214
+ expect(rawColours(sheet('.a { color: rgb(var(--color-fg-r) 2 3); }'))).toHaveLength(1);
215
+ expect(rawColours(sheet('.a { color: rgb(var(--color-fg-x, #ff0000)); }'))).toHaveLength(1);
216
+ });
217
+
218
+ // A Sass \`#{…}\` interpolation is a value that carries braces, and the declaration scan has to read
219
+ // PAST it: \`role($name, $alpha)\` emits exactly this, so a value stopping at the \`{\` leaves the
220
+ // commonest token form of all undecided rather than accepted.
221
+ unitTest('an interpolated alpha is still the token form, and is read whole', () => {
222
+ expect(rawColours(sheet('.a { color: rgb(var(--color-fg) / #{$alpha}); }'))).toEqual([]);
223
+ expect(rawColours(sheet('.a { color: rgb(1 2 3 / #{$alpha}); }'))).toHaveLength(1);
224
+ });
225
+
122
226
  unitTest('a token, a selector and a quoted filename are not colours', () => {
123
227
  expect(rawColours(sheet(".a { background: tokens.role('bg'); }"))).toEqual([]);
124
228
  expect(rawColours(sheet('#hero { padding: 0; }'))).toEqual([]);