@ultimat3/cli 19.4.0 → 20.1.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/CLAUDE.md +56 -3
- package/package.json +29 -29
- package/src/budgets.ts +47 -2
- package/src/cmd-doctor.ts +97 -3
- package/src/cmd-shot-island.ts +120 -15
- package/src/cmd-shot.ts +45 -20
- package/src/index.ts +8 -2
- package/src/island-capture.ts +278 -0
- package/src/island-harness-script.ts +10 -1
- package/src/island-shot-index.ts +155 -0
- package/src/island-shot.ts +106 -276
- package/src/island-verdict.ts +90 -1
- package/src/messages.ts +2 -1
- package/src/prerender.ts +16 -1
- package/src/sw-artifacts.ts +9 -2
- package/src/templates/github/ci.yml.ts +74 -0
- package/src/templates/guard-animated-layout-property.ts +269 -0
- package/src/templates/guard-focus-visible.ts +240 -0
- package/src/templates/guard-image-dimensions.ts +225 -0
- package/src/templates/guard-island-without-states.ts +128 -0
- package/src/templates/guard-raw-colour.ts +112 -8
- package/src/templates/guard-semantic-interactive.ts +244 -0
- package/src/templates/guard-untranslated-string.ts +54 -6
- package/src/templates/island.ts +44 -1
- package/src/templates/resource-form-island.ts +67 -0
- package/src/templates/scaffold-claude-agents.ts +10 -1
- package/src/templates/scaffold-claude-commands.ts +20 -9
- package/src/templates/scaffold-docs.ts +40 -5
- package/src/templates/scaffold-guards.ts +15 -0
- package/src/templates/scaffold-repo.ts +22 -2
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// The `semantic-interactive` guard `x new` ships: an element that answers a click IS a control.
|
|
2
|
+
// The cheapest a11y win there is, and the one an agent undoes by reflex — WebAIM's annual survey of
|
|
3
|
+
// a million home pages finds pages using ARIA average ~41% MORE detected errors than pages using
|
|
4
|
+
// none, because `role="button"` is a PROMISE (Space, Enter, focus, disabled) and only the native
|
|
5
|
+
// element keeps it. Nothing in the gate could see a `<div onClick>` before this file existed.
|
|
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 = 'semantic-interactive';
|
|
17
|
+
const CODE = guardCode(NAME);
|
|
18
|
+
|
|
19
|
+
const source =
|
|
20
|
+
(): string => `// semantic-interactive: a click is answered by a control, and a role is a promise.
|
|
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
|
+
/**
|
|
30
|
+
* Elements with no behaviour of their own: no tab stop, no Enter, no Space, no disabled state.
|
|
31
|
+
* A handler on one of these reaches a mouse and nothing else.
|
|
32
|
+
*/
|
|
33
|
+
const INERT = new Set([
|
|
34
|
+
'div',
|
|
35
|
+
'span',
|
|
36
|
+
'li',
|
|
37
|
+
'p',
|
|
38
|
+
'section',
|
|
39
|
+
'article',
|
|
40
|
+
'aside',
|
|
41
|
+
'header',
|
|
42
|
+
'footer',
|
|
43
|
+
'main',
|
|
44
|
+
'nav',
|
|
45
|
+
'ul',
|
|
46
|
+
'ol',
|
|
47
|
+
'td',
|
|
48
|
+
'tr',
|
|
49
|
+
'h1',
|
|
50
|
+
'h2',
|
|
51
|
+
'h3',
|
|
52
|
+
'h4',
|
|
53
|
+
'h5',
|
|
54
|
+
'h6',
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
/** A role whose native element already exists, and the element to write instead of the role. */
|
|
58
|
+
const NATIVE = new Map([
|
|
59
|
+
['button', { tag: 'button', write: '<button type="button">' }],
|
|
60
|
+
['link', { tag: 'a', write: '<a href="…">' }],
|
|
61
|
+
['checkbox', { tag: 'input', write: '<input type="checkbox">' }],
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** \`(?<![\\w-])\` on every one: \`data-role\` is not \`role\`, and \`maxWidth\` is not \`width\`. */
|
|
65
|
+
const HANDLER = /(?<![\\w-])(on(?:Click|MouseDown|KeyDown))\\s*=/;
|
|
66
|
+
const ROLE = /(?<![\\w-])role\\s*=\\s*["']([a-z]+)["']/;
|
|
67
|
+
const TABINDEX = /(?<![\\w-])tab[Ii]ndex\\s*=/;
|
|
68
|
+
const KEYS = /(?<![\\w-])onKey(?:Down|Up|Press)\\s*=/;
|
|
69
|
+
|
|
70
|
+
export interface SourceFile {
|
|
71
|
+
/** App-root-relative POSIX path, so the finding names the file an author opens. */
|
|
72
|
+
readonly path: string;
|
|
73
|
+
readonly source: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Tag {
|
|
77
|
+
readonly name: string;
|
|
78
|
+
readonly attrs: string;
|
|
79
|
+
readonly index: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Comments blanked IN PLACE — not deleted — so a reported line number still points at the source. */
|
|
83
|
+
const blank = (text: string): string =>
|
|
84
|
+
text
|
|
85
|
+
.replaceAll(/\\/\\*[\\s\\S]*?\\*\\//g, (match) => match.replaceAll(/[^\\n]/g, ' '))
|
|
86
|
+
.replaceAll(/(?<![:\\w])\\/\\/[^\\n]*/g, (match) => ' '.repeat(match.length));
|
|
87
|
+
|
|
88
|
+
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
89
|
+
|
|
90
|
+
const NAME_AT = /^([A-Za-z][\\w.-]*)/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Every opening tag, with its attribute text. The tag ends at the first \`>\` OUTSIDE braces and
|
|
94
|
+
* quotes, which is the whole reason this is a scanner and not a regex: \`onClick={() => save()}\`
|
|
95
|
+
* holds a \`>\` that closes nothing, so a pattern reading to the next \`>\` cuts the element in half
|
|
96
|
+
* and misses every handler written as an arrow — the way all of them are written.
|
|
97
|
+
*/
|
|
98
|
+
function openingTags(text: string): readonly Tag[] {
|
|
99
|
+
const tags: Tag[] = [];
|
|
100
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
101
|
+
if (text[i] !== '<') continue;
|
|
102
|
+
const name = NAME_AT.exec(text.slice(i + 1, i + 64))?.[1];
|
|
103
|
+
if (name === undefined) continue;
|
|
104
|
+
const from = i + 1 + name.length;
|
|
105
|
+
let depth = 0;
|
|
106
|
+
let quote = '';
|
|
107
|
+
let end = from;
|
|
108
|
+
for (; end < text.length; end += 1) {
|
|
109
|
+
const ch = text[end];
|
|
110
|
+
if (quote !== '') {
|
|
111
|
+
if (ch === quote) quote = '';
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (ch === '"' || ch === "'" || ch === '\`') quote = ch;
|
|
115
|
+
else if (ch === '{') depth += 1;
|
|
116
|
+
else if (ch === '}') depth -= 1;
|
|
117
|
+
else if (depth === 0 && (ch === '>' || ch === '<')) break;
|
|
118
|
+
}
|
|
119
|
+
tags.push({ name, attrs: text.slice(from, end), index: i });
|
|
120
|
+
// Continue from just after the NAME, never after the tag: a nested element inside a brace
|
|
121
|
+
// expression — \`{items.map((i) => <li onClick={…}>…)}\` — is a tag this rule has to see.
|
|
122
|
+
i = from - 1;
|
|
123
|
+
}
|
|
124
|
+
return tags;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
128
|
+
export function semanticInteractive(files: readonly SourceFile[]): readonly Finding[] {
|
|
129
|
+
const findings: Finding[] = [];
|
|
130
|
+
for (const file of files) {
|
|
131
|
+
const text = blank(file.source);
|
|
132
|
+
for (const tag of openingTags(text)) {
|
|
133
|
+
// A role, a tab stop and a key handler together are the COMPLETE set the ARIA practices
|
|
134
|
+
// guide asks for — a deliberate widget, written by someone who read the obligation. This
|
|
135
|
+
// rule is about the incomplete ones, so all three present is the one shape it passes over.
|
|
136
|
+
if (ROLE.test(tag.attrs) && TABINDEX.test(tag.attrs) && KEYS.test(tag.attrs)) continue;
|
|
137
|
+
const at = \`\${file.path}:\${lineOf(text, tag.index)}\`;
|
|
138
|
+
const role = ROLE.exec(tag.attrs)?.[1] ?? '';
|
|
139
|
+
const native = NATIVE.get(role);
|
|
140
|
+
// One finding per tag, role first: \`<div role="button" onClick>\` is one mistake with two
|
|
141
|
+
// symptoms, and two findings would make the reader fix it twice.
|
|
142
|
+
if (native !== undefined && tag.name !== native.tag) {
|
|
143
|
+
findings.push({
|
|
144
|
+
code: CODE,
|
|
145
|
+
cause: \`\${at} puts role="\${role}" on <\${tag.name}> — a role is a promise, and this one obliges the element to answer Space, Enter, focus and disabled, which the native element already does\`,
|
|
146
|
+
fix: \`replace <\${tag.name} role="\${role}"> in \${file.path} with \${native.write}, then: x verify\`,
|
|
147
|
+
at: file.path,
|
|
148
|
+
});
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const handler = INERT.has(tag.name) ? HANDLER.exec(tag.attrs)?.[1] : undefined;
|
|
152
|
+
if (handler === undefined) continue;
|
|
153
|
+
findings.push({
|
|
154
|
+
code: CODE,
|
|
155
|
+
cause: \`\${at} hangs \${handler} on <\${tag.name}>, which takes no focus and answers no key — a keyboard, a screen reader and a switch reach a mouse handler on an inert element in exactly one way, which is not at all\`,
|
|
156
|
+
fix: \`write <button type="button"> — or the native control this element means — in place of <\${tag.name}> at \${at}, then: x verify\`,
|
|
157
|
+
at: file.path,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return findings;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const guard: Guard = {
|
|
165
|
+
summary: 'a click is answered by a control, never by a div with a handler',
|
|
166
|
+
async check(root) {
|
|
167
|
+
const files: SourceFile[] = [];
|
|
168
|
+
// TWO globs, and the rule is that a brace ALTERNATIVE may not contain a \`/\`. Measured on Bun
|
|
169
|
+
// 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
|
|
170
|
+
// \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
|
|
171
|
+
// matches 17 — so folding these into one line silently turns the guard off, which is worse than
|
|
172
|
+
// the hole it closes. A LEADING group is fine and four guards here rely on it:
|
|
173
|
+
// \`{apps,packages}/**/*.scss\` matches all 15.
|
|
174
|
+
for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
|
|
175
|
+
for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
|
|
176
|
+
const path = entry.split('\\\\').join('/');
|
|
177
|
+
if (path.includes('node_modules/') || /\\.test\\.tsx?$/.test(path)) continue;
|
|
178
|
+
files.push({ path, source: await Bun.file(\`\${root}/\${path}\`).text() });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return semanticInteractive(files);
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
`;
|
|
185
|
+
|
|
186
|
+
const test =
|
|
187
|
+
(): string => `// The rule, driven directly. Failure case first: a guard whose rule silently stopped matching is
|
|
188
|
+
// a green gate over the convention it was written to enforce.
|
|
189
|
+
|
|
190
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
191
|
+
import { semanticInteractive } from './semantic-interactive';
|
|
192
|
+
|
|
193
|
+
const file = (source: string) => [{ path: 'apps/web/app/post/page.tsx', source }];
|
|
194
|
+
|
|
195
|
+
unitTest('a div with a click handler is refused, and the finding names the line', () => {
|
|
196
|
+
const findings = semanticInteractive(
|
|
197
|
+
file('<main>\\n <div onClick={() => save()}>Save</div>\\n</main>'),
|
|
198
|
+
);
|
|
199
|
+
expect(findings).toHaveLength(1);
|
|
200
|
+
expect(findings[0]?.code).toBe('${CODE}');
|
|
201
|
+
expect(findings[0]?.cause).toContain(':2');
|
|
202
|
+
expect(findings[0]?.fix).toContain('<button type="button">');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
unitTest('role="button" on a div names the native element to write instead', () => {
|
|
206
|
+
const findings = semanticInteractive(file('<div role="button" onClick={go}>Go</div>'));
|
|
207
|
+
expect(findings).toHaveLength(1);
|
|
208
|
+
expect(findings[0]?.cause).toContain('a role is a promise');
|
|
209
|
+
expect(findings[0]?.fix).toContain('<button type="button">');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
unitTest('the native control it names is not itself a finding', () => {
|
|
213
|
+
expect(semanticInteractive(file('<button type="button" onClick={go}>Go</button>'))).toEqual([]);
|
|
214
|
+
expect(semanticInteractive(file('<a href="/x" onClick={go}>Go</a>'))).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// The boundary, stated: a role, a tab stop and a key handler together are the complete set the
|
|
218
|
+
// ARIA practices guide asks for. This rule reports the INCOMPLETE widget, never the deliberate one.
|
|
219
|
+
unitTest('a role with a tab stop and a key handler is a deliberate widget', () => {
|
|
220
|
+
const widget = '<div role="button" tabindex="0" onClick={go} onKeyDown={go}>Go</div>';
|
|
221
|
+
expect(semanticInteractive(file(widget))).toEqual([]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
unitTest('data-role is not role, and a live region is not a control', () => {
|
|
225
|
+
expect(semanticInteractive(file('<p data-role="status" role="status">Saved</p>'))).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// The reason the tag end is scanned rather than matched: an arrow function's \`>\` closes nothing,
|
|
229
|
+
// and a pattern that stopped at it would read the element as ending before its own handler.
|
|
230
|
+
unitTest('an arrow function in an earlier attribute does not end the tag', () => {
|
|
231
|
+
const source = '<div class={cx(() => a > b)} onClick={go}>Go</div>';
|
|
232
|
+
expect(semanticInteractive(file(source))).toHaveLength(1);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
unitTest('a commented-out handler is a note, not an element', () => {
|
|
236
|
+
expect(semanticInteractive(file('// <div onClick={go}>Go</div>\\nconst a = 1;'))).toEqual([]);
|
|
237
|
+
});
|
|
238
|
+
`;
|
|
239
|
+
|
|
240
|
+
/** `guards/semantic-interactive.ts` and its test. The directory is the registration. */
|
|
241
|
+
export const semanticInteractiveGuardFiles = (): readonly GeneratedFile[] => [
|
|
242
|
+
{ path: 'guards/semantic-interactive.ts', contents: source() },
|
|
243
|
+
{ path: 'guards/semantic-interactive.test.ts', contents: test() },
|
|
244
|
+
];
|
|
@@ -36,8 +36,6 @@ const CODE = '${CODE}';
|
|
|
36
36
|
* rule wants: a parent whose children are elements has no text of its own.
|
|
37
37
|
*/
|
|
38
38
|
const ELEMENT = /<([A-Za-z][\\w.:-]*)(?:\\s[^<>]*)?>([^<>]*?)<\\/\\1>/g;
|
|
39
|
-
/** A \`{…}\` child is an expression — \`{t('key')}\`, \`{props.row.title}\` — never typed prose. */
|
|
40
|
-
const EXPRESSION = /\\{[^{}]*\\}/g;
|
|
41
39
|
/** Two word characters in a row. One is \`&\`, \`×\`, an initial — never a sentence. */
|
|
42
40
|
const PROSE = /[\\p{L}\\p{N}]{2,}/u;
|
|
43
41
|
|
|
@@ -55,13 +53,43 @@ const blank = (text: string): string =>
|
|
|
55
53
|
|
|
56
54
|
const lineOf = (text: string, index: number): number => text.slice(0, index).split('\\n').length;
|
|
57
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Every \`{…}\` child removed — \`{t('key')}\`, \`{props.row.title}\` — leaving only what was TYPED
|
|
58
|
+
* between the tags.
|
|
59
|
+
*
|
|
60
|
+
* A depth scan and never a regex, because a JSX expression NESTS and a regex does not:
|
|
61
|
+
* \`/\\{[^{}]*\\}/g\` strips the INNER group of
|
|
62
|
+
* \`{t('app.feed.heading', { org: actor.org.name })}\` first, leaves the unbalanced remnant
|
|
63
|
+
* \`{t('app.feed.heading', )}\`, and then reads that remnant as prose — so every \`t()\` call with an
|
|
64
|
+
* interpolation object or a template-literal key was reported as an untranslated string, which is
|
|
65
|
+
* the exact opposite of the rule. Measured at 12 findings, all false, before this scan replaced it.
|
|
66
|
+
*
|
|
67
|
+
* A closing brace with nothing open is kept: it is a stray character, and prose it is not.
|
|
68
|
+
*/
|
|
69
|
+
const withoutExpressions = (children: string): string => {
|
|
70
|
+
let out = '';
|
|
71
|
+
let depth = 0;
|
|
72
|
+
for (const character of children) {
|
|
73
|
+
if (character === '{') {
|
|
74
|
+
depth += 1;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (character === '}' && depth > 0) {
|
|
78
|
+
depth -= 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (depth === 0) out += character;
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
|
|
58
86
|
/** Pure — the caller does the I/O — so the rule is testable without a filesystem. */
|
|
59
87
|
export function untranslatedStrings(files: readonly SourceFile[]): readonly Finding[] {
|
|
60
88
|
const findings: Finding[] = [];
|
|
61
89
|
for (const file of files) {
|
|
62
90
|
const text = blank(file.source);
|
|
63
91
|
for (const match of text.matchAll(ELEMENT)) {
|
|
64
|
-
const typed = (match[2] ?? '').
|
|
92
|
+
const typed = withoutExpressions(match[2] ?? '').trim();
|
|
65
93
|
if (!PROSE.test(typed)) continue;
|
|
66
94
|
findings.push({
|
|
67
95
|
code: CODE,
|
|
@@ -83,9 +111,12 @@ export const guard: Guard = {
|
|
|
83
111
|
// a hardcoded string there used to be green. \`api/\` renders nothing and \`shared/\` is a leaf of
|
|
84
112
|
// helpers; \`packages/*/dist\` is a build output, not source.
|
|
85
113
|
//
|
|
86
|
-
// TWO globs,
|
|
87
|
-
//
|
|
88
|
-
//
|
|
114
|
+
// TWO globs, and the rule is that a brace ALTERNATIVE may not contain a \`/\`. Measured on Bun
|
|
115
|
+
// 1.4.0 against \`examples/dummy\`: \`{apps/*/{site,app},packages/*/src}/**/*.tsx\` and
|
|
116
|
+
// \`{apps/web,packages/ui}/**/*.tsx\` each match ZERO files, where \`apps/*/{site,app}/**/*.tsx\`
|
|
117
|
+
// matches 17 — so folding these into one line silently turns the guard off, which is worse than
|
|
118
|
+
// the hole it closes. A LEADING group is fine and four guards here rely on it:
|
|
119
|
+
// \`{apps,packages}/**/*.scss\` matches all 15.
|
|
89
120
|
for (const pattern of ['apps/*/{site,app}/**/*.tsx', 'packages/*/src/**/*.tsx']) {
|
|
90
121
|
for await (const entry of new Bun.Glob(pattern).scan({ cwd: root, absolute: false })) {
|
|
91
122
|
const path = entry.split('\\\\').join('/');
|
|
@@ -119,6 +150,23 @@ unitTest('a t() child satisfies it, and so does any other expression', () => {
|
|
|
119
150
|
expect(untranslatedStrings(file('<li class={styles.item}>{row.title}</li>'))).toEqual([]);
|
|
120
151
|
});
|
|
121
152
|
|
|
153
|
+
// The legitimate lookalike, and the one this rule got wrong: a JSX expression NESTS. A mask that
|
|
154
|
+
// does not strips the inner \`{ org: … }\` first and reads the unbalanced remnant as prose, so the
|
|
155
|
+
// guard reported the very calls it exists to require.
|
|
156
|
+
unitTest('a t() call carrying an interpolation object is not typed prose', () => {
|
|
157
|
+
const interpolated = "<h1>{t('app.feed.heading', { org: actor.org.name })}</h1>";
|
|
158
|
+
expect(untranslatedStrings(file(interpolated))).toEqual([]);
|
|
159
|
+
expect(untranslatedStrings(file(\`<span>{t(\\\`plans.\\\${plan}.name\\\`)}</span>\`))).toEqual([]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// The other direction: masking a nested child may not swallow the prose beside it.
|
|
163
|
+
unitTest('prose beside an interpolating t() call is still refused', () => {
|
|
164
|
+
const mixed = "<h1>{t('app.feed.heading', { org: actor.org.name })} Welcome back</h1>";
|
|
165
|
+
const findings = untranslatedStrings(file(mixed));
|
|
166
|
+
expect(findings).toHaveLength(1);
|
|
167
|
+
expect(findings[0]?.cause).toContain('Welcome back');
|
|
168
|
+
});
|
|
169
|
+
|
|
122
170
|
// The reason the rule reads a CLOSING tag: a generic type argument is a > followed by source that
|
|
123
171
|
// looks exactly like prose, and a pattern reading to the next < reports every one of them.
|
|
124
172
|
unitTest('a generic type argument is not a JSX text node', () => {
|
package/src/templates/island.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// compiles. All three are pinned by the emitted test, which builds the chunk and mounts it.
|
|
6
6
|
|
|
7
7
|
import type { GeneratedFile } from './naming';
|
|
8
|
-
import { kebab, pascal } from './naming';
|
|
8
|
+
import { camel, kebab, pascal } from './naming';
|
|
9
9
|
|
|
10
10
|
export interface IslandOptions {
|
|
11
11
|
/** Directory the entry lands in, app-root-relative and POSIX — normally a route's own folder. */
|
|
@@ -170,12 +170,55 @@ describe('the ${name} island', () => {
|
|
|
170
170
|
});
|
|
171
171
|
`;
|
|
172
172
|
|
|
173
|
+
/**
|
|
174
|
+
* The states file beside the entry, and it ships WITH the island rather than after it: `x verify`'s
|
|
175
|
+
* `boundaries` step refuses an island that declares none (`guards/island-without-states.ts`), so a
|
|
176
|
+
* generator that wrote only the component would scaffold a file that fails the app's own gate on
|
|
177
|
+
* the next command. The second state is the point of the whole mechanism — a label a translation
|
|
178
|
+
* is three times as long in is a state nobody can reach by clicking in the locale they develop in.
|
|
179
|
+
*/
|
|
180
|
+
const islandStates = (name: string, dir: string): string => {
|
|
181
|
+
const Name = pascal(name);
|
|
182
|
+
return `// The states \`${name}\` can be photographed in. \`x shot --island ${name} --json\` takes one
|
|
183
|
+
// picture per state per theme into \`.x/shot/island/${name}/\`, and the states worth declaring are
|
|
184
|
+
// the ones a running app will not produce on request.
|
|
185
|
+
//
|
|
186
|
+
// PURE DATA. No JSX, no \`solid-js\`, and the one import below is \`import type\`, which
|
|
187
|
+
// \`verbatimModuleSyntax\` erases entirely — the command that takes the pictures has to know the
|
|
188
|
+
// complete expected list before a browser exists. \`X_TEST_ISLAND_STATES_NOT_PURE\` is the refusal.
|
|
189
|
+
//
|
|
190
|
+
// The labels are literals here and that is not a \`t()\` violation: an island's props cross the seam
|
|
191
|
+
// as JSON inside the document, so the SERVER translates and the browser is handed text.
|
|
192
|
+
|
|
193
|
+
import { defineIslandStates } from '@ultimat3/testing';
|
|
194
|
+
import type { ${Name}Props } from './${name}.island';
|
|
195
|
+
|
|
196
|
+
export const ${camel(name)}States = defineIslandStates({
|
|
197
|
+
island: '${dir}/${name}.island.tsx',
|
|
198
|
+
states: [
|
|
199
|
+
{
|
|
200
|
+
id: 'idle',
|
|
201
|
+
title: 'the first paint, before anything has been clicked',
|
|
202
|
+
props: { label: 'Open' } satisfies ${Name}Props,
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
id: 'long-label',
|
|
206
|
+
title: 'the label a translation is three times as long in',
|
|
207
|
+
note: 'you cannot reach this by clicking: it needs a locale whose word for this is long, and the one this was written in is not it',
|
|
208
|
+
props: { label: 'Abrechnungseinstellungen anzeigen' } satisfies ${Name}Props,
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
`;
|
|
213
|
+
};
|
|
214
|
+
|
|
173
215
|
export function islandFiles(rawName: string, options: IslandOptions): readonly GeneratedFile[] {
|
|
174
216
|
const name = kebab(rawName);
|
|
175
217
|
const dir = options.dir.replace(/\/+$/, '');
|
|
176
218
|
return [
|
|
177
219
|
{ path: `${dir}/${name}.island.tsx`, contents: islandSource(name) },
|
|
178
220
|
{ path: `${dir}/${name}.module.scss`, contents: islandStyle() },
|
|
221
|
+
{ path: `${dir}/${name}.island.states.ts`, contents: islandStates(name, dir) },
|
|
179
222
|
{ path: `${dir}/${name}.island.test.ts`, contents: islandTest(name, dir) },
|
|
180
223
|
];
|
|
181
224
|
}
|
|
@@ -265,6 +265,69 @@ describe('the ${feature.kebab} form island', () => {
|
|
|
265
265
|
});
|
|
266
266
|
`;
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* The states file beside the entry, and it ships WITH the island rather than after it: `x verify`'s
|
|
270
|
+
* `boundaries` step refuses an island that declares none (`guards/island-without-states.ts`), so a
|
|
271
|
+
* generator that wrote only the component would scaffold a file that fails the app's own gate on
|
|
272
|
+
* the next command.
|
|
273
|
+
*/
|
|
274
|
+
const formIslandStates = (
|
|
275
|
+
feature: NameSet,
|
|
276
|
+
dir: string,
|
|
277
|
+
): string => `// The states the ${feature.kebab} form can be photographed in. \`x shot --island ${feature.kebab}-form --json\`
|
|
278
|
+
// takes one picture per state per theme into \`.x/shot/island/${feature.kebab}-form/\`, and the states
|
|
279
|
+
// worth declaring are the ones a running app will not produce on request.
|
|
280
|
+
//
|
|
281
|
+
// PURE DATA. No JSX, no \`solid-js\`, and the one import below is \`import type\`, which
|
|
282
|
+
// \`verbatimModuleSyntax\` erases entirely — the command that takes the pictures has to know the
|
|
283
|
+
// complete expected list before a browser exists. \`X_TEST_ISLAND_STATES_NOT_PURE\` is the refusal.
|
|
284
|
+
//
|
|
285
|
+
// The labels are literals here and that is not a \`t()\` violation: an island's props cross the seam
|
|
286
|
+
// as JSON inside the document, so the SERVER translates and the browser is handed text. These are
|
|
287
|
+
// the text the server would have handed it.
|
|
288
|
+
|
|
289
|
+
import { defineIslandStates } from '@ultimat3/testing';
|
|
290
|
+
import type { ${feature.pascal}FormProps } from './${feature.kebab}-form.island';
|
|
291
|
+
|
|
292
|
+
/** What a working render hands the form — the baseline the state below departs from. */
|
|
293
|
+
const BASE = {
|
|
294
|
+
endpoint: '/api/create-${feature.kebab}',
|
|
295
|
+
locale: 'en',
|
|
296
|
+
labels: {
|
|
297
|
+
title: 'Title',
|
|
298
|
+
submit: 'Save',
|
|
299
|
+
saved: 'Saved',
|
|
300
|
+
retry: 'That did not save. Try again.',
|
|
301
|
+
},
|
|
302
|
+
} satisfies ${feature.pascal}FormProps;
|
|
303
|
+
|
|
304
|
+
export const ${feature.camel}FormStates = defineIslandStates({
|
|
305
|
+
island: '${dir}/${feature.kebab}-form.island.tsx',
|
|
306
|
+
states: [
|
|
307
|
+
{
|
|
308
|
+
id: 'idle',
|
|
309
|
+
title: 'the first paint, before anything has been typed',
|
|
310
|
+
props: BASE satisfies ${feature.pascal}FormProps,
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
id: 'long-labels',
|
|
314
|
+
title: 'the same form in a locale whose words are three times as long',
|
|
315
|
+
note: 'you cannot reach this by clicking: it needs a translation, and the locale this was written in is the one that fits',
|
|
316
|
+
props: {
|
|
317
|
+
...BASE,
|
|
318
|
+
locale: 'de',
|
|
319
|
+
labels: {
|
|
320
|
+
title: 'Bezeichnung des Beitrags',
|
|
321
|
+
submit: 'Änderungen speichern',
|
|
322
|
+
saved: 'Änderungen gespeichert',
|
|
323
|
+
retry: 'Das konnte nicht gespeichert werden. Bitte erneut versuchen.',
|
|
324
|
+
},
|
|
325
|
+
} satisfies ${feature.pascal}FormProps,
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
});
|
|
329
|
+
`;
|
|
330
|
+
|
|
268
331
|
/**
|
|
269
332
|
* The slice's form, as the one client shape: `<dir>/<feature>-form.island.tsx` plus its test.
|
|
270
333
|
*
|
|
@@ -281,6 +344,10 @@ export function formIslandFiles(
|
|
|
281
344
|
path: `${dir}/${feature.kebab}-form.island.tsx`,
|
|
282
345
|
contents: formIslandSource(feature, formIslandSpecifier(feature, dir, pageDir)),
|
|
283
346
|
},
|
|
347
|
+
{
|
|
348
|
+
path: `${dir}/${feature.kebab}-form.island.states.ts`,
|
|
349
|
+
contents: formIslandStates(feature, dir),
|
|
350
|
+
},
|
|
284
351
|
{
|
|
285
352
|
path: `${dir}/${feature.kebab}-form.island.test.ts`,
|
|
286
353
|
contents: formIslandTest(feature, dir),
|
|
@@ -131,8 +131,17 @@ calls. A change outside that set is a collision — report it, do not make it.
|
|
|
131
131
|
| Semantic tokens only | never a raw hex, in a component or a stylesheet |
|
|
132
132
|
| Dates name a zone | explicit IANA time zone at every call site, no ambient default |
|
|
133
133
|
| A page calls primitives | queries and actions, never a repo and never the database |
|
|
134
|
+
| A click needs a control | \`<button>\`, \`<a href>\`, \`<input>\` — never a \`<div onClick>\`, and never \`role="button"\` where the tag exists. A role is a promise: it obliges you to answer Space, Enter, focus and disabled |
|
|
135
|
+
| The focus ring is replaced | \`outline: none\` on its own leaves a keyboard user with nothing. \`@include tokens.focus-ring\`, or a \`:focus-visible\` box-shadow beside it |
|
|
136
|
+
| Every image has a box | width + height, or an aspect-ratio. An unsized image moves everything under it when its bytes land, and the priority one is never \`loading="lazy"\` |
|
|
137
|
+
| Animate transform and opacity | nothing else composites: a \`top\`, a \`width\` or a \`box-shadow\` costs a layout pass every frame, and \`transition: all\` animates properties nobody chose. \`tokens.duration()\` / \`tokens.easing()\` are the values |
|
|
138
|
+
| An island declares its states | a sibling \`<name>.island.states.ts\`, so \`x shot --island <name> --json\` can photograph the failures nobody can click to |
|
|
134
139
|
|
|
135
|
-
|
|
140
|
+
Every one of the last five is a build error, not a preference — \`guards/\` holds them and \`x verify\`'s
|
|
141
|
+
\`boundaries\` step runs them. Read the guard rather than guessing at the rule.
|
|
142
|
+
|
|
143
|
+
Inspect before you change: \`x routes --json\`, \`x i18n check\` for catalog gaps, and
|
|
144
|
+
\`x shot <route> --json\` when you need to see what you built.
|
|
136
145
|
|
|
137
146
|
Checks: \`bun test <path>/page.test.ts\`, \`bunx biome check --write <paths>\`, and \`bun run typecheck\`
|
|
138
147
|
once when you are otherwise done. Never \`x verify\` — that belongs to whoever coordinates you.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import type { GeneratedFile, NameSet } from './naming';
|
|
7
7
|
|
|
8
8
|
const feature = (app: NameSet): string => `---
|
|
9
|
-
description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`
|
|
9
|
+
description: Build or fix one thing in ${app.kebab} end to end — name the primitive, generate it, wire it inside the boundaries, gate it with \`bin/check\`.
|
|
10
10
|
argument-hint: <what you want built or fixed, plain language>
|
|
11
11
|
allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
|
|
12
12
|
---
|
|
@@ -16,7 +16,7 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash, Agent, Skill
|
|
|
16
16
|
You are a senior engineer on **${app.kebab}**, an Ultimate app. Read \`AGENTS.md\` before designing
|
|
17
17
|
anything — it is the short form of every rule below, and it wins where the two disagree.
|
|
18
18
|
|
|
19
|
-
**Done means \`
|
|
19
|
+
**Done means \`bin/check\` green.** A passing unit test is not done. A working \`x dev\` is not done.
|
|
20
20
|
Report what you actually ran, never what you assume passed.
|
|
21
21
|
|
|
22
22
|
## Request
|
|
@@ -77,10 +77,16 @@ the filename never is. One interactive control on a 0kb page is an island: \`x g
|
|
|
77
77
|
## 4. Gate it
|
|
78
78
|
|
|
79
79
|
\`\`\`sh
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
bin/check # the gate. green = shippable
|
|
81
|
+
bin/check --json # the same, machine-readable
|
|
82
82
|
\`\`\`
|
|
83
83
|
|
|
84
|
+
\`bin/check\` is \`x build --target static\` and THEN \`x verify\`, and the order is the whole point:
|
|
85
|
+
the \`budgets\` step measures \`.x/build-stats.json\`, which only a build writes, so \`x verify\` on
|
|
86
|
+
its own reports X_BUDGET_UNMEASURED on a tree that is fine. \`--json\` is forwarded to both halves;
|
|
87
|
+
a machine consumer takes the last line. CI runs this same script — \`.github/workflows/ci.yml\` is
|
|
88
|
+
\`bin/setup\` and then \`bin/check\`, nothing else.
|
|
89
|
+
|
|
84
90
|
Red is instructions, not a verdict: **every finding carries an executable \`fix:\` — run it verbatim
|
|
85
91
|
before improvising**, and \`x errors explain <CODE>\` expands any code it names. Never narrow the gate
|
|
86
92
|
to make it pass: there is no \`--only\` and no \`--skip\`, on purpose, and disabling a lint rule or
|
|
@@ -112,7 +118,7 @@ ISO code, never a float. Bun only.
|
|
|
112
118
|
Primitive: <which of the eight> Slice: <dir>
|
|
113
119
|
Generated: <the x g invocations you ran>
|
|
114
120
|
Changed: <files>
|
|
115
|
-
Gate:
|
|
121
|
+
Gate: bin/check ✓ | ✗ <failing steps>
|
|
116
122
|
Deferred: <what you did not do, and why> [never omit this line]
|
|
117
123
|
\`\`\`
|
|
118
124
|
`;
|
|
@@ -166,7 +172,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
|
|
|
166
172
|
- What to add, next to the source as \`<file>.test.ts\`. Command to run it.
|
|
167
173
|
|
|
168
174
|
## Done when
|
|
169
|
-
- Acceptance criteria, ending in \`
|
|
175
|
+
- Acceptance criteria, ending in \`bin/check\` green.
|
|
170
176
|
|
|
171
177
|
## Risks
|
|
172
178
|
- Anything the executor must decide, and every claim in the ask the code disproves.
|
|
@@ -179,7 +185,7 @@ slice it lives in. If it fits none, the design is wrong — say so here instead
|
|
|
179
185
|
- No checkboxes. The plan is a map, not a tracker.
|
|
180
186
|
- The plan must obey the app's own rules — one way to do each thing, generators over hand-written
|
|
181
187
|
files, imports that never cross a surface boundary, a stable error code with a runnable \`fix:\` for
|
|
182
|
-
every new failure, and \`
|
|
188
|
+
every new failure, and \`bin/check\` green as the last line of *Done when*.
|
|
183
189
|
|
|
184
190
|
## Output
|
|
185
191
|
|
|
@@ -196,13 +202,18 @@ allowed-tools: Read, Write, Edit, Glob, Grep, Bash
|
|
|
196
202
|
|
|
197
203
|
# /verify
|
|
198
204
|
|
|
199
|
-
Run \`
|
|
205
|
+
Run \`bin/check\`.
|
|
206
|
+
|
|
207
|
+
It is \`x build --target static\` and then \`x verify\`, and it is THE gate — the one CI runs
|
|
208
|
+
(\`.github/workflows/ci.yml\`) and the one \`README.md\` names. \`x verify\` alone is half of it:
|
|
209
|
+
the \`budgets\` step measures \`.x/build-stats.json\`, which only the build writes, so a bare
|
|
210
|
+
\`x verify\` reports X_BUDGET_UNMEASURED on a tree with nothing wrong with it.
|
|
200
211
|
|
|
201
212
|
Green: say so and stop.
|
|
202
213
|
|
|
203
214
|
Red: fix every finding, then re-run until green. Each finding carries a stable code, a cause and an
|
|
204
215
|
executable \`fix:\` — **run the \`fix:\` verbatim before improvising**, and use \`x errors explain <CODE>\`
|
|
205
|
-
when the cause is not enough. \`
|
|
216
|
+
when the cause is not enough. \`bin/check --json\` gives the same steps machine-readably; \`x doctor\`
|
|
206
217
|
covers the case where the environment, not the code, is what is broken.
|
|
207
218
|
|
|
208
219
|
Do not narrow the gate to make it pass. There is no \`--only\` and no \`--skip\`; disabling a lint rule,
|