@estiva-app/ui 0.35.1 → 0.36.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/dist/eslint/escape.d.ts +23 -6
- package/dist/eslint/escape.d.ts.map +1 -1
- package/dist/eslint/index.js +70 -9
- package/dist/eslint/index.js.map +2 -2
- package/dist/eslint/no-raw-element.d.ts +7 -0
- package/dist/eslint/no-raw-element.d.ts.map +1 -1
- package/dist/eslint/no-restyled-part.d.ts +8 -0
- package/dist/eslint/no-restyled-part.d.ts.map +1 -1
- package/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-DRIHHXQK.js → chunk-CC6L4B5U.js} +23 -17
- package/dist/gates/chunk-CC6L4B5U.js.map +7 -0
- package/dist/gates/chunk-SYVKGOWM.js +1131 -0
- package/dist/gates/chunk-SYVKGOWM.js.map +7 -0
- package/dist/gates/ci.d.ts +18 -0
- package/dist/gates/ci.d.ts.map +1 -0
- package/dist/gates/cli.js +20 -3
- package/dist/gates/cli.js.map +2 -2
- package/dist/gates/create-app.d.ts.map +1 -1
- package/dist/gates/create-app.js +1 -1
- package/dist/gates/escapes.d.ts +23 -0
- package/dist/gates/escapes.d.ts.map +1 -0
- package/dist/gates/gate-config.d.ts.map +1 -1
- package/dist/gates/hook.d.ts +2 -0
- package/dist/gates/hook.d.ts.map +1 -1
- package/dist/gates/index.d.ts +5 -1
- package/dist/gates/index.d.ts.map +1 -1
- package/dist/gates/index.js +41 -185
- package/dist/gates/index.js.map +4 -4
- package/dist/gates/name-escapes.d.ts +14 -0
- package/dist/gates/name-escapes.d.ts.map +1 -0
- package/dist/gates/status.d.ts +3 -1
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/gates/token-lint.d.ts.map +1 -1
- package/dist/gates/token-style.d.ts +21 -0
- package/dist/gates/token-style.d.ts.map +1 -0
- package/dist/index.js +13 -13
- package/dist/index.js.map +1 -1
- package/dist/registry/{app-3ALZE6XO.js → app-YMABQII6.js} +3 -3
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/behaviours.d.ts +71 -0
- package/dist/registry/behaviours.d.ts.map +1 -0
- package/dist/registry/{build-NDXMVFQ5.js → build-3EANARLQ.js} +2 -2
- package/dist/registry/build.d.ts.map +1 -1
- package/dist/registry/{chunk-3WJ7ZZ7Z.js → chunk-EVJW26UR.js} +51 -4
- package/dist/registry/chunk-EVJW26UR.js.map +7 -0
- package/dist/registry/{chunk-XVII7P6G.js → chunk-SIB44CES.js} +276 -90
- package/dist/registry/chunk-SIB44CES.js.map +7 -0
- package/dist/registry/{chunk-KMZIMB3G.js → chunk-XK747STJ.js} +46 -3
- package/dist/registry/chunk-XK747STJ.js.map +7 -0
- package/dist/registry/cli.js +93 -34
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/contract.d.ts +27 -1
- package/dist/registry/contract.d.ts.map +1 -1
- package/dist/registry/index.d.ts +1 -1
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +11 -5
- package/dist/registry/siblings.d.ts +9 -0
- package/dist/registry/siblings.d.ts.map +1 -1
- package/dist/registry/storymap.d.ts +19 -5
- package/dist/registry/storymap.d.ts.map +1 -1
- package/package.json +4 -1
- package/registry.json +293 -17
- package/skill/estiva-ui.md +2 -1
- package/src/AppShell.mdx +2 -1
- package/src/AttachmentCard.tsx +7 -7
- package/src/Button.tsx +1 -1
- package/src/Card.tsx +2 -2
- package/src/ChipInput.mdx +2 -0
- package/src/ChipInput.tsx +1 -1
- package/src/CommandPalette.tsx +3 -3
- package/src/DialogShell.tsx +1 -1
- package/src/EmptyState.mdx +4 -3
- package/src/Lightbox.tsx +1 -1
- package/src/NavItem.tsx +1 -1
- package/src/PersonTrigger.tsx +1 -1
- package/src/Reaction.tsx +1 -1
- package/src/ScrollArea.mdx +4 -0
- package/src/Select.tsx +1 -1
- package/src/TopBar.mdx +1 -1
- package/src/eslint/escape.ts +43 -14
- package/src/eslint/has-a-page-and-a-story.test.ts +1 -1
- package/src/eslint/index.test.ts +2 -2
- package/src/eslint/no-copied-look.test.ts +1 -1
- package/src/eslint/no-hand-rolled-behaviour.test.ts +3 -3
- package/src/eslint/no-handmade-empty-state.test.ts +2 -2
- package/src/eslint/no-handmade-header.test.ts +2 -2
- package/src/eslint/no-native-title.test.ts +2 -2
- package/src/eslint/no-raw-element.test.ts +47 -15
- package/src/eslint/no-raw-element.ts +53 -3
- package/src/eslint/no-rebuilt-behaviour.test.ts +9 -9
- package/src/eslint/no-restyled-part.test.ts +21 -2
- package/src/eslint/no-restyled-part.ts +30 -0
- package/src/eslint/raw-element-outside-a-wrapper.test.ts +3 -3
- package/src/gates/app-checks.ts +4 -3
- package/src/gates/ci.ts +152 -0
- package/src/gates/cli.ts +19 -3
- package/src/gates/count.ts +1 -1
- package/src/gates/create-app.test.ts +2 -2
- package/src/gates/create-app.ts +22 -16
- package/src/gates/escapes.ts +111 -0
- package/src/gates/gate-config.ts +6 -2
- package/src/gates/gates.test.ts +93 -1
- package/src/gates/hook.ts +44 -6
- package/src/gates/index.ts +5 -1
- package/src/gates/name-escapes.ts +77 -0
- package/src/gates/status.ts +45 -2
- package/src/gates/token-lint.ts +11 -4
- package/src/gates/token-style.ts +79 -0
- package/src/how.test.ts +282 -0
- package/src/pages.test.ts +17 -0
- package/src/registry/app.test.ts +34 -0
- package/src/registry/app.ts +52 -2
- package/src/registry/behaviours.ts +223 -0
- package/src/registry/build.ts +36 -0
- package/src/registry/cli.ts +37 -18
- package/src/registry/contract.test.ts +63 -3
- package/src/registry/contract.ts +75 -2
- package/src/registry/index.ts +1 -1
- package/src/registry/registry.test.ts +14 -0
- package/src/registry/siblings.test.ts +24 -1
- package/src/registry/siblings.ts +23 -0
- package/src/registry/storymap.test.ts +52 -31
- package/src/registry/storymap.ts +76 -37
- package/stories/Choosing.mdx +8 -2
- package/dist/gates/chunk-DRIHHXQK.js.map +0 -7
- package/dist/gates/chunk-GI6M4P7H.js +0 -606
- package/dist/gates/chunk-GI6M4P7H.js.map +0 -7
- package/dist/registry/chunk-3WJ7ZZ7Z.js.map +0 -7
- package/dist/registry/chunk-KMZIMB3G.js.map +0 -7
- package/dist/registry/chunk-XVII7P6G.js.map +0 -7
- /package/dist/registry/{app-3ALZE6XO.js.map → app-YMABQII6.js.map} +0 -0
- /package/dist/registry/{build-NDXMVFQ5.js.map → build-3EANARLQ.js.map} +0 -0
package/src/gates/create-app.ts
CHANGED
|
@@ -284,7 +284,8 @@ import reactHooks from 'eslint-plugin-react-hooks'
|
|
|
284
284
|
* TypeScript's the package names itself.
|
|
285
285
|
*
|
|
286
286
|
* A place that keeps something the gate refuses says why, on the line above:
|
|
287
|
-
* \`// @estiva-escape: <reason
|
|
287
|
+
* \`// @estiva-escape(<rule>): <reason>\`, naming the rule it keeps off. Never
|
|
288
|
+
* \`eslint-disable\`: the count refuses it.
|
|
288
289
|
*/
|
|
289
290
|
export default gateConfig({ quiet: { 'react-hooks': reactHooks } })
|
|
290
291
|
`,
|
|
@@ -316,8 +317,14 @@ export default function define(h) {
|
|
|
316
317
|
Nothing. ${title} was made with every gate on, at zero.
|
|
317
318
|
|
|
318
319
|
It should stay that way. A place that keeps something the gate refuses says why on
|
|
319
|
-
the line above it, \`// @estiva-escape: <reason
|
|
320
|
-
|
|
320
|
+
the line above it, \`// @estiva-escape(<rule>): <reason>\`. This page does not list those:
|
|
321
|
+
\`npm run gates:status -- --escapes\` does, from the code, with each one's age, and
|
|
322
|
+
\`npm run gates:status\` shows the ones older than 30 days, to keep with a new reason or fix.
|
|
323
|
+
|
|
324
|
+
## Folders the gate does not read
|
|
325
|
+
|
|
326
|
+
None. A folder the gate skips is named here, with why; CI's job \`gate\` fails on one
|
|
327
|
+
that is not.
|
|
321
328
|
`,
|
|
322
329
|
|
|
323
330
|
'.github/workflows/deploy.yml': `name: deploy
|
|
@@ -382,16 +389,14 @@ jobs:
|
|
|
382
389
|
node-version: 24
|
|
383
390
|
cache: npm
|
|
384
391
|
- run: npm ci
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
- name: Every part is described
|
|
394
|
-
run: npm run registry:check
|
|
392
|
+
# Every step of the gate, the same command Peek and Ship run: the gate
|
|
393
|
+
# lint and \`.gates-count.json\` (committed, or this fails), the token
|
|
394
|
+
# contract, the catalogue (every part says in one line what it is for),
|
|
395
|
+
# and the wall's own settings: the editor hook in .claude/settings.json
|
|
396
|
+
# refuses a raw button, and every folder the gate skips is named in
|
|
397
|
+
# docs/GATES-DEBT.md.
|
|
398
|
+
- name: The gate (estiva-gates ci)
|
|
399
|
+
run: npx estiva-gates ci
|
|
395
400
|
`,
|
|
396
401
|
|
|
397
402
|
'src/index.css': `@import '@estiva-app/ui/tokens.css';
|
|
@@ -996,12 +1001,13 @@ nothing arrives.
|
|
|
996
1001
|
|---|---|
|
|
997
1002
|
| \`npm run typecheck\` | TypeScript, the app and its settings files |
|
|
998
1003
|
| \`npm run lint\` | everything: TypeScript's and React's rules, the token contract, the gate |
|
|
999
|
-
| \`npm run lint:rules\` | the gate alone, and \`.gates-count.json\`
|
|
1004
|
+
| \`npm run lint:rules\` | the gate alone, and \`.gates-count.json\` |
|
|
1000
1005
|
| \`npm test\` | the tests |
|
|
1001
1006
|
| \`npm run build\` | the build |
|
|
1002
1007
|
| \`npm run gates:status\` | which gates are on, read from the code |
|
|
1003
1008
|
| \`npm run ui:find <words>\` | what the package and this app already have for it |
|
|
1004
|
-
| \`npm run registry:check\` | every part says what it is for
|
|
1009
|
+
| \`npm run registry:check\` | every part says what it is for |
|
|
1010
|
+
| \`npx estiva-gates ci\` | every step of CI's job \`gate\`: the gate, its count committed, the tokens, the catalogue, the hook |
|
|
1005
1011
|
| \`npm run storybook\` | the stories |
|
|
1006
1012
|
|
|
1007
1013
|
The gates are the package's, imported rather than copied, so a rule written later
|
|
@@ -1026,7 +1032,7 @@ instead. What it refuses is the package's to say, and grows with it: the
|
|
|
1026
1032
|
(the hook in \`.claude/settings.json\`, for a session started in this folder), in
|
|
1027
1033
|
\`npm run lint:rules\` and in CI. A session started elsewhere: run
|
|
1028
1034
|
\`npm run lint:rules\` after changing \`src/\`, and fix what it reports. Keep something
|
|
1029
|
-
only with its reason on the line above, \`// @estiva-escape: <reason>\`, never with
|
|
1035
|
+
only with its reason on the line above, \`// @estiva-escape(<rule>): <reason>\`, never with
|
|
1030
1036
|
\`eslint-disable\`.
|
|
1031
1037
|
|
|
1032
1038
|
**Tokens only.** Colours, type, corners and shadows come from the package's preset.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* Every escape in a repository, listed (Katerina's ruling B1, 25 September):
|
|
4
|
+
* where it is, which rules it keeps off, why, and how old it is.
|
|
5
|
+
*
|
|
6
|
+
* The count (`.gates-count.json`) holds numbers per rule; the plan asked for
|
|
7
|
+
* escapes "listed in a report, reviewed", and the hand-written debt pages had
|
|
8
|
+
* drifted from the count in all three repos. This reads the markers themselves,
|
|
9
|
+
* both forms:
|
|
10
|
+
*
|
|
11
|
+
* - the gate's, `// @estiva-escape(<rule>): <reason>` (or in a JSX comment);
|
|
12
|
+
* - the token lint's, `// eslint-disable-next-line <rule> -- @estiva-escape: <reason>`
|
|
13
|
+
* (ruling A2, 15 September), which no count sees.
|
|
14
|
+
*
|
|
15
|
+
* Age comes from git (the day the marker's line was last written), so it cannot
|
|
16
|
+
* be forgotten or faked. Review is by age, monthly: `gates:status` shows every
|
|
17
|
+
* escape older than {@link REVIEW_AFTER_DAYS} days, and Katerina keeps it (with
|
|
18
|
+
* a new reason) or it gets fixed.
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from 'node:child_process'
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
22
|
+
import { join, relative } from 'node:path'
|
|
23
|
+
import { readMarker } from '../eslint/escape'
|
|
24
|
+
|
|
25
|
+
/** An escape older than this many days is shown for review. */
|
|
26
|
+
export const REVIEW_AFTER_DAYS = 30
|
|
27
|
+
|
|
28
|
+
export interface Escape {
|
|
29
|
+
file: string
|
|
30
|
+
line: number
|
|
31
|
+
/** The rules it keeps off, as the marker names them. */
|
|
32
|
+
rules: string[]
|
|
33
|
+
reason: string
|
|
34
|
+
/** Whole days since the marker's line was written, or null outside git. */
|
|
35
|
+
days: number | null
|
|
36
|
+
/** `gate` for the gate's marker, `token` for the token lint's directive. */
|
|
37
|
+
kind: 'gate' | 'token'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const SOURCE = /\.[cm]?[jt]sx?$/
|
|
41
|
+
const SKIP = new Set(['node_modules', 'dist', '.git', 'storybook-static', 'coverage'])
|
|
42
|
+
const DIRECTIVE = /eslint-disable(?:-next-line|-line)?\s+(.*?)\s+--\s+@estiva-escape\s*:?\s*(.*)$/
|
|
43
|
+
|
|
44
|
+
function sourceFiles(dir: string, out: string[] = []): string[] {
|
|
45
|
+
if (!existsSync(dir)) return out
|
|
46
|
+
for (const name of readdirSync(dir)) {
|
|
47
|
+
if (SKIP.has(name)) continue
|
|
48
|
+
const path = join(dir, name)
|
|
49
|
+
if (statSync(path).isDirectory()) sourceFiles(path, out)
|
|
50
|
+
else if (SOURCE.test(name) && !/\.test\.[cm]?[jt]sx?$/.test(name)) out.push(path)
|
|
51
|
+
}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The day each line of a file was last written, from `git blame`, in seconds since 1970. */
|
|
56
|
+
function lineTimes(root: string, file: string): Map<number, number> {
|
|
57
|
+
const times = new Map<number, number>()
|
|
58
|
+
try {
|
|
59
|
+
const out = execFileSync('git', ['blame', '--line-porcelain', '--', relative(root, file)], { cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 << 20 }).toString()
|
|
60
|
+
let line = 0
|
|
61
|
+
for (const row of out.split('\n')) {
|
|
62
|
+
const header = /^[0-9a-f]{40} \d+ (\d+)/.exec(row)
|
|
63
|
+
if (header) line = Number(header[1])
|
|
64
|
+
else if (row.startsWith('author-time ')) times.set(line, Number(row.slice(12)))
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
// Not in git, or a file git does not track yet: its escapes have no age.
|
|
68
|
+
}
|
|
69
|
+
return times
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Every escape under `src`, and in any other folder named, oldest first. */
|
|
73
|
+
export function listEscapes(root: string, { folders = ['src'], now = Date.now() }: { folders?: string[]; now?: number } = {}): Escape[] {
|
|
74
|
+
const found: Escape[] = []
|
|
75
|
+
for (const file of folders.flatMap((f) => sourceFiles(join(root, f)))) {
|
|
76
|
+
const lines = readFileSync(file, 'utf8').split(/\r?\n/)
|
|
77
|
+
const hits: Omit<Escape, 'days'>[] = []
|
|
78
|
+
lines.forEach((text, i) => {
|
|
79
|
+
if (!text.includes('@estiva-escape')) return
|
|
80
|
+
const rel = relative(root, file).split('\\').join('/')
|
|
81
|
+
// A real marker opens its comment; documentation mentions one mid-sentence, or with a placeholder.
|
|
82
|
+
if (/<(reason|why|rule)\b/.test(text)) return
|
|
83
|
+
const directive = /^\s*(?:\/\/|\{?\/\*)\s*eslint-disable/.test(text) ? DIRECTIVE.exec(text) : null
|
|
84
|
+
if (directive) {
|
|
85
|
+
hits.push({ file: rel, line: i + 1, rules: directive[1].split(/[\s,]+/).filter(Boolean), reason: directive[2].replace(/\*\/\s*}?\s*$/, '').trim(), kind: 'token' })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
if (!/^\s*(?:\/\/+|\{?\/\*+)?\s*@estiva-escape(?:\s*\(|\s*:)/.test(text)) return
|
|
89
|
+
const marker = readMarker(text)
|
|
90
|
+
if (!marker) return
|
|
91
|
+
hits.push({ file: rel, line: i + 1, rules: marker.rules, reason: marker.reason.replace(/\*\/\s*}?\s*$/, '').trim(), kind: 'gate' })
|
|
92
|
+
})
|
|
93
|
+
if (!hits.length) continue
|
|
94
|
+
const times = lineTimes(root, file)
|
|
95
|
+
for (const hit of hits) {
|
|
96
|
+
const at = times.get(hit.line)
|
|
97
|
+
found.push({ ...hit, days: at === undefined ? null : Math.floor((now - at * 1000) / 86_400_000) })
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return found.sort((a, b) => (b.days ?? -1) - (a.days ?? -1) || a.file.localeCompare(b.file) || a.line - b.line)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The lines `gates:status` prints: a total, and every escape due for review (or every one, with `all`). */
|
|
104
|
+
export function escapeLines(escapes: Escape[], { all = false }: { all?: boolean } = {}): string[] {
|
|
105
|
+
const due = escapes.filter((e) => (e.days ?? 0) > REVIEW_AFTER_DAYS)
|
|
106
|
+
const shown = all ? escapes : due
|
|
107
|
+
const gate = escapes.filter((e) => e.kind === 'gate').length
|
|
108
|
+
const out = [`Escapes: ${escapes.length} (${gate} of the gate's, ${escapes.length - gate} of the token lint's) · ${due.length} older than ${REVIEW_AFTER_DAYS} days, to review: keep with a new reason, or fix${all ? '' : ' · every escape: add -- --escapes'}`]
|
|
109
|
+
for (const e of shown) out.push(` ${e.file}:${e.line} ${e.rules.join(', ') || '(no rule named)'} · ${e.days === null ? 'not in git yet' : `${e.days} days`}\n ${e.reason}`)
|
|
110
|
+
return out
|
|
111
|
+
}
|
package/src/gates/gate-config.ts
CHANGED
|
@@ -21,6 +21,7 @@ import betterTailwindcss from 'eslint-plugin-better-tailwindcss'
|
|
|
21
21
|
import { parser as typescriptParser } from 'typescript-eslint'
|
|
22
22
|
import estiva from '../eslint/index'
|
|
23
23
|
import { KNOWN_PLUGINS, TOKEN_LINT_IGNORES } from './token-lint'
|
|
24
|
+
import { tokenStylePlugin } from './token-style'
|
|
24
25
|
|
|
25
26
|
export interface GateConfigOptions {
|
|
26
27
|
/** `app` (the default) runs `configs.recommended`; `package` runs this package's inward set. */
|
|
@@ -44,8 +45,10 @@ export interface GateConfigOptions {
|
|
|
44
45
|
export function gateLint({ audience = 'app' }: Pick<GateConfigOptions, 'audience'> = {}): Linter.Config {
|
|
45
46
|
return {
|
|
46
47
|
...(audience === 'package' ? estiva.configs.package : estiva.configs.recommended),
|
|
47
|
-
|
|
48
|
-
|
|
48
|
+
// An app's every script type: a raw <button> in a .jsx, or a key listener in a .mts,
|
|
49
|
+
// passed while only .ts and .tsx were read (the re-review after the audit before UIG-26).
|
|
50
|
+
files: audience === 'package' ? ['src/**/*.tsx'] : ['src/**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'],
|
|
51
|
+
ignores: ['**/*.test.*'],
|
|
49
52
|
languageOptions: {
|
|
50
53
|
parser: typescriptParser,
|
|
51
54
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
|
@@ -68,6 +71,7 @@ export function gateConfig({ audience = 'app', quiet = {}, ignores = [] }: GateC
|
|
|
68
71
|
'better-tailwindcss': betterTailwindcss,
|
|
69
72
|
'token-values': betterTailwindcss,
|
|
70
73
|
'token-spacing': betterTailwindcss,
|
|
74
|
+
'token-style': tokenStylePlugin,
|
|
71
75
|
},
|
|
72
76
|
// Every other config's directives are unused here, because their rules are
|
|
73
77
|
// off here. Reporting them would be this config complaining about the
|
package/src/gates/gates.test.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { pathToFileURL } from 'node:url'
|
|
5
5
|
import { ESLint, type Linter } from 'eslint'
|
|
6
6
|
import { afterAll, describe, expect, it } from 'vitest'
|
|
7
|
+
import { countCommitted, hookRuns, skippedFolders, skipsListed } from './ci'
|
|
7
8
|
import { writeGateCount } from './count'
|
|
8
9
|
import { gateConfig, gateLint } from './gate-config'
|
|
9
10
|
import { runHook } from './hook'
|
|
@@ -78,6 +79,18 @@ describe('tokenLint and tokenValues', () => {
|
|
|
78
79
|
expect(await lintWith([tokenLint()], "import { cn } from '@estiva-app/ui'\nexport const merged = cn('p-2', 'p-3')\n", 'src/lib/probe.ts')).toEqual([])
|
|
79
80
|
})
|
|
80
81
|
|
|
82
|
+
it('refuse a colour in a style with its key in quotes, or kept in a const (R9)', async () => {
|
|
83
|
+
const quoted = await lintWith([tokenLint(), tokenValues()], component(`<div style={{ 'background': 'red' }}>x</div>`), 'src/Probe.tsx')
|
|
84
|
+
expect(quoted.filter((m) => m.severity === 2).map((m) => m.ruleId)).toEqual(['no-restricted-syntax'])
|
|
85
|
+
const kept = `const look = { background: 'red', width: 20 } as const\n${component('<div style={look}>x</div>')}`
|
|
86
|
+
const constant = await lintWith([tokenLint(), tokenValues()], kept, 'src/Probe.tsx')
|
|
87
|
+
expect(constant.filter((m) => m.severity === 2)).toEqual([expect.objectContaining({ ruleId: 'token-style/no-token-style', line: 1 })])
|
|
88
|
+
// A size in a const, and a style worked out while it runs, pass.
|
|
89
|
+
expect((await lintWith([tokenLint(), tokenValues()], `const at = { width: 20 }\n${component('<div style={at}><div style={props.style} /></div>')}`, 'src/Probe.tsx')).filter((m) => m.severity === 2)).toEqual([])
|
|
90
|
+
// Its escape works in the gate too: the gate knows the rule's name.
|
|
91
|
+
expect(await lintWith(gateConfig(), `// eslint-disable-next-line token-style/no-token-style -- @estiva-escape: a colour the probe keeps\n${kept}`, 'src/Probe.tsx')).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
81
94
|
it('leave a test file to its test', async () => {
|
|
82
95
|
const messages = await lintWith([tokenLint(), tokenValues()], component('<div style={{ color: "red" }}>x</div>'), 'src/Probe.test.tsx')
|
|
83
96
|
expect(messages.filter((m) => m.severity === 2)).toEqual([])
|
|
@@ -147,7 +160,7 @@ describe('tokenConfig', () => {
|
|
|
147
160
|
describe('writeGateCount', () => {
|
|
148
161
|
it('writes the count once, leaves it alone when nothing changed, and fails on a rule switched off', async () => {
|
|
149
162
|
const dir = app('count', {
|
|
150
|
-
'src/Kept.tsx': component('(\n // @estiva-escape: a probe that keeps its element on purpose\n <form />\n )'),
|
|
163
|
+
'src/Kept.tsx': component('(\n // @estiva-escape(no-raw-element): a probe that keeps its element on purpose\n <form />\n )'),
|
|
151
164
|
})
|
|
152
165
|
const first = await writeGateCount({ root: dir, repo: 'probe' })
|
|
153
166
|
expect(first.changed).toBe(true)
|
|
@@ -164,6 +177,47 @@ describe('writeGateCount', () => {
|
|
|
164
177
|
})
|
|
165
178
|
})
|
|
166
179
|
|
|
180
|
+
describe('estiva-gates ci: the steps no lint runs (B2, B13)', () => {
|
|
181
|
+
const git = (dir: string, ...args: string[]) => execFileSync('git', ['-c', 'user.name=probe', '-c', 'user.email=probe@example.com', ...args], { cwd: dir, stdio: 'ignore' })
|
|
182
|
+
|
|
183
|
+
it('fails when the count the gate writes is not the committed one', async () => {
|
|
184
|
+
const dir = app('ci-count', { 'src/Plain.tsx': component('<div>x</div>') })
|
|
185
|
+
git(dir, 'init', '-q')
|
|
186
|
+
await writeGateCount({ root: dir, repo: 'probe' })
|
|
187
|
+
git(dir, 'add', '.')
|
|
188
|
+
git(dir, 'commit', '-q', '-m', 'count')
|
|
189
|
+
expect(countCommitted(dir)).toEqual([])
|
|
190
|
+
writeFileSync(join(dir, 'src', 'Kept.tsx'), component('(\n // @estiva-escape(no-raw-element): a probe that keeps its element on purpose\n <form />\n )'))
|
|
191
|
+
await writeGateCount({ root: dir, repo: 'probe' })
|
|
192
|
+
const moved = countCommitted(dir)
|
|
193
|
+
expect(moved[0]).toContain('commit `.gates-count.json`')
|
|
194
|
+
expect(moved.join('\n')).toContain('"escapes": 1')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('fails when no hook runs before both Write and Edit, or the hook lets a raw button through', () => {
|
|
198
|
+
const cli = join(root, 'dist', 'gates', 'cli.js').split('\\').join('/')
|
|
199
|
+
const settings = (matcher: string, command: string) => JSON.stringify({ hooks: { PreToolUse: [{ matcher, hooks: [{ type: 'command', command }] }] } })
|
|
200
|
+
const dir = app('ci-hook', { '.claude/settings.json': settings('Edit|Write', `node "${cli}" hook`) })
|
|
201
|
+
expect(hookRuns(dir)).toEqual([])
|
|
202
|
+
writeFileSync(join(dir, '.claude', 'settings.json'), settings('Write', `node "${cli}" hook`))
|
|
203
|
+
expect(hookRuns(dir)[0]).toContain('before both Write and Edit')
|
|
204
|
+
writeFileSync(join(dir, '.claude', 'settings.json'), settings('Edit|Write', 'node -e "process.exit(0)"'))
|
|
205
|
+
expect(hookRuns(dir)[0]).toContain('let a raw button through')
|
|
206
|
+
rmSync(join(dir, '.claude'), { recursive: true })
|
|
207
|
+
expect(hookRuns(dir)[0]).toContain('missing')
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('fails when the gate skips a folder the debt page does not name', async () => {
|
|
211
|
+
const dir = app('ci-skips', { 'docs/GATES-DEBT.md': '# Debt\n\nNothing skipped.\n' })
|
|
212
|
+
expect(await skipsListed(dir, 'app')).toEqual([])
|
|
213
|
+
writeFileSync(join(dir, 'eslint.gates.config.js'), `import { gateConfig } from '${built}'\nexport default gateConfig({ ignores: ['demo-scenarios/**'] })\n`)
|
|
214
|
+
expect(await skippedFolders(dir)).toEqual(['demo-scenarios/**'])
|
|
215
|
+
expect((await skipsListed(dir, 'app')).join('\n')).toContain('demo-scenarios/**')
|
|
216
|
+
writeFileSync(join(dir, 'docs', 'GATES-DEBT.md'), '# Debt\n\n`demo-scenarios` is not checked: fixtures for the demo, never shipped.\n')
|
|
217
|
+
expect(await skipsListed(dir, 'app')).toEqual([])
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
167
221
|
describe('runHook', () => {
|
|
168
222
|
const dir = app('hook', { 'src/Page.tsx': 'export function Page() {\n return <div>x</div>\n}\n' })
|
|
169
223
|
const write = (file_path: string, content: string) => ({ tool_name: 'Write', tool_input: { file_path, content } })
|
|
@@ -182,6 +236,36 @@ describe('runHook', () => {
|
|
|
182
236
|
expect((await runHook({ root: dir, input: fine })).code).toBe(0)
|
|
183
237
|
})
|
|
184
238
|
|
|
239
|
+
// B4 (Katerina, 25 September): the token contract is refused before the write, not only in CI.
|
|
240
|
+
it('refuses a hand-written size and a raw colour before the write, naming the token', async () => {
|
|
241
|
+
const result = await runHook({ root: dir, input: write('src/Probe.tsx', component('<div className="text-[13px] bg-[#ff0000]">x</div>')) })
|
|
242
|
+
expect(result.code).toBe(2)
|
|
243
|
+
expect(result.message).toContain('token-values/no-restricted-classes')
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// R10: a copied look only warns, so the write goes through, with a note Claude sees.
|
|
247
|
+
it('lets a copied look through with a note, and no note when nothing is copied', async () => {
|
|
248
|
+
const own = app('hook-copied', {
|
|
249
|
+
// An app's Tailwind config, on the package preset: the token lint knows its classes from it.
|
|
250
|
+
'tailwind.config.js': `import estiva from '${pathToFileURL(join(root, 'tailwind-preset.js')).href}'\nexport default { presets: [estiva], content: ['./src/**/*.tsx'] }\n`,
|
|
251
|
+
'src/Badge.tsx': '/** A badge. */\nexport function Badge() {\n return <span className="inline-flex items-center rounded-md border border-border-default bg-bg-elevated px-2 text-caption text-text-secondary">x</span>\n}\n',
|
|
252
|
+
// Already there, so the write is an edit of a part, not a new one the search stops.
|
|
253
|
+
'src/Probe.tsx': component('<span>y</span>'),
|
|
254
|
+
})
|
|
255
|
+
const copied = await runHook({ root: own, input: write('src/Probe.tsx', component('<span className="inline-flex items-center rounded-md border border-border-default bg-bg-elevated px-2 text-caption text-text-secondary">y</span>')) })
|
|
256
|
+
expect(copied.code).toBe(0)
|
|
257
|
+
expect(copied.note).toContain('Badge')
|
|
258
|
+
const plain = await runHook({ root: own, input: write('src/Probe.tsx', component('<span className="mt-2">y</span>')) })
|
|
259
|
+
expect(plain).toEqual({ code: 0, message: '' })
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// The re-review after the audit before UIG-26: only .ts and .tsx were read, so these passed.
|
|
263
|
+
it('reads every script type under src: a raw button in a .jsx, a page listener in a .mts', async () => {
|
|
264
|
+
expect((await runHook({ root: dir, input: write('src/Probe.jsx', component('<button type="button">x</button>')) })).code).toBe(2)
|
|
265
|
+
expect((await runHook({ root: dir, input: write('src/lib/probe.mts', "export function listen() {\n document.addEventListener('keydown', () => {})\n}\n") })).code).toBe(2)
|
|
266
|
+
expect((await runHook({ root: dir, input: write('src/Probe.test.jsx', component('<button type="button">x</button>')) })).code).toBe(0)
|
|
267
|
+
})
|
|
268
|
+
|
|
185
269
|
it('lets through a test, a declaration file, and anything outside src', async () => {
|
|
186
270
|
for (const path of ['src/Probe.test.tsx', 'src/types.d.ts', 'scripts/probe.tsx']) {
|
|
187
271
|
expect((await runHook({ root: dir, input: write(path, component('<button type="button">x</button>')) })).code).toBe(0)
|
|
@@ -235,6 +319,14 @@ describe('gates:status', () => {
|
|
|
235
319
|
expect(h.ciJob('gate', 'lint:rules').result).toBe('pass')
|
|
236
320
|
expect(h.ciJob('check', 'lint:rules').result).toBe('fail')
|
|
237
321
|
expect(h.script('package.json', 'gates:status').result).toBe('pass')
|
|
322
|
+
// A job that runs `estiva-gates ci` runs the gate's scripts (R13), and nothing else.
|
|
323
|
+
writeFileSync(join(dir, '.github', 'workflows', 'deploy.yml'), 'jobs:\n gate:\n steps:\n - run: npx estiva-gates ci\n')
|
|
324
|
+
expect(h.ciJob('gate', 'lint:rules').result).toBe('pass')
|
|
325
|
+
expect(h.ciJob('gate', 'registry:check').result).toBe('pass')
|
|
326
|
+
expect(h.ci('lint:tokens').result).toBe('pass')
|
|
327
|
+
expect(h.ciJob('gate', 'test').result).toBe('fail')
|
|
328
|
+
writeFileSync(join(dir, '.github', 'workflows', 'deploy.yml'), 'jobs:\n gate:\n steps:\n # - run: npx estiva-gates ci\n - run: npm test\n')
|
|
329
|
+
expect(h.ciJob('gate', 'lint:rules').result).toBe('fail')
|
|
238
330
|
})
|
|
239
331
|
|
|
240
332
|
// Audit A2: a hook is judged by running it, the way Claude Code does, on a raw
|
package/src/gates/hook.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
* UIG-4 findings).
|
|
14
14
|
*
|
|
15
15
|
* It runs the gate's own config and reimplements nothing: a hook and a lint
|
|
16
|
-
* that could disagree would be worse than neither.
|
|
16
|
+
* that could disagree would be worse than neither. Since 0.36.0 it runs the
|
|
17
|
+
* token contract too, keeping only that contract's errors (B4), and a copied
|
|
18
|
+
* look, which only warns, reaches Claude as a note on a write let through (R10). Tests are not checked
|
|
17
19
|
* (Katerina, 13 September); stories are. This package's inward gate reads `.tsx`
|
|
18
20
|
* only, as its lint does.
|
|
19
21
|
*
|
|
@@ -37,8 +39,9 @@ import { createRequire } from 'node:module'
|
|
|
37
39
|
import { tmpdir } from 'node:os'
|
|
38
40
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
39
41
|
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
40
|
-
import type { ESLint as ESLintClass } from 'eslint'
|
|
42
|
+
import type { ESLint as ESLintClass, Linter } from 'eslint'
|
|
41
43
|
import { countGates } from '../eslint/index'
|
|
44
|
+
import { tokenConfig } from './token-lint'
|
|
42
45
|
import { repositoryOf } from '../registry/skill'
|
|
43
46
|
|
|
44
47
|
export interface HookOptions {
|
|
@@ -59,6 +62,27 @@ export interface HookResult {
|
|
|
59
62
|
code: 0 | 2
|
|
60
63
|
/** What goes to stderr when refused. */
|
|
61
64
|
message: string
|
|
65
|
+
/** A note for Claude on a write let through: a copied look, which only warns. */
|
|
66
|
+
note?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The token contract's own rules: what the hook keeps from its lint, and nothing else. */
|
|
70
|
+
const TOKEN_RULE_IDS = new Set(tokenConfig().flatMap((c) => Object.keys(c.rules ?? {})))
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The token contract's errors for the text about to be written. The app's own
|
|
74
|
+
* token config when it has one (`eslint.tokens.config.js`, which knows the app's
|
|
75
|
+
* other plugins' names); otherwise the package's `tokenConfig`. Only the token
|
|
76
|
+
* rules' errors count, so an app's wider lint (TypeScript's, React's) never
|
|
77
|
+
* blocks a write that is still being made.
|
|
78
|
+
*/
|
|
79
|
+
async function tokenErrors(ESLint: typeof ESLintClass, appDir: string, file: string, text: string, audience: 'app' | 'package'): Promise<Linter.LintMessage[]> {
|
|
80
|
+
const own = join(appDir, 'eslint.tokens.config.js')
|
|
81
|
+
const eslint = existsSync(own)
|
|
82
|
+
? new ESLint({ cwd: appDir, overrideConfigFile: own })
|
|
83
|
+
: new ESLint({ cwd: appDir, overrideConfigFile: true, overrideConfig: tokenConfig({ audience }) })
|
|
84
|
+
const results = await eslint.lintText(text, { filePath: file })
|
|
85
|
+
return results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId && TOKEN_RULE_IDS.has(m.ruleId)))
|
|
62
86
|
}
|
|
63
87
|
|
|
64
88
|
interface ToolCall {
|
|
@@ -69,6 +93,8 @@ interface ToolCall {
|
|
|
69
93
|
}
|
|
70
94
|
|
|
71
95
|
const PASS: HookResult = { code: 0, message: '' }
|
|
96
|
+
/** An app's source the gate reads: every script type, with or without JSX. */
|
|
97
|
+
const APP_SOURCE = /^src\/.+\.[cm]?[jt]sx?$/
|
|
72
98
|
|
|
73
99
|
export async function runHook({ root, app = '.', audience = 'app', input, seen }: HookOptions = {}): Promise<HookResult> {
|
|
74
100
|
let call: ToolCall
|
|
@@ -84,8 +110,10 @@ export async function runHook({ root, app = '.', audience = 'app', input, seen }
|
|
|
84
110
|
const appDir = resolve(top, app)
|
|
85
111
|
const file = isAbsolute(args.file_path) ? args.file_path : resolve(call.cwd ?? top, args.file_path)
|
|
86
112
|
const rel = relative(appDir, file).split(sep).join('/')
|
|
87
|
-
|
|
88
|
-
|
|
113
|
+
// Every script under an app's src/, as the gate reads it (a raw <button> in a .jsx passed
|
|
114
|
+
// before, the re-review after the audit before UIG-26); the package's own gate is .tsx only.
|
|
115
|
+
const source = audience === 'package' ? /^src\/.+\.tsx$/ : APP_SOURCE
|
|
116
|
+
if (rel.startsWith('../') || isAbsolute(rel) || !source.test(rel) || /\.test\.[cm]?[jt]sx?$/.test(rel) || /\.d\.[cm]?ts$/.test(rel)) return PASS
|
|
89
117
|
|
|
90
118
|
const text = proposed(call.tool_name, args, file)
|
|
91
119
|
if (text === null) return PASS
|
|
@@ -99,14 +127,24 @@ export async function runHook({ root, app = '.', audience = 'app', input, seen }
|
|
|
99
127
|
// A rule's errors only. Code that does not parse yet (one edit of several) is
|
|
100
128
|
// the typecheck's to judge; the gate looks again at the edit that completes it.
|
|
101
129
|
const errors = results.flatMap((r) => r.messages.filter((m) => m.severity === 2 && m.ruleId))
|
|
130
|
+
// The token contract too (Katerina's ruling B4, 25 September): a colour or a size
|
|
131
|
+
// is refused before the write, as CI's gate refuses it, not only at the PR.
|
|
132
|
+
errors.push(...(await tokenErrors(ESLint, appDir, file, text, audience)))
|
|
102
133
|
const { disabled } = countGates(results)
|
|
103
|
-
if (errors.length === 0 && disabled.length === 0)
|
|
134
|
+
if (errors.length === 0 && disabled.length === 0) {
|
|
135
|
+
const stopped = searchFirst(call, file, text, appDir, top, seen)
|
|
136
|
+
if (stopped) return stopped
|
|
137
|
+
// A copied look is a warning (C2): it never blocks, and Claude hears of it now,
|
|
138
|
+
// not only in CI (R10, 25 September).
|
|
139
|
+
const copies = results.flatMap((r) => r.messages.filter((m) => m.severity === 1 && m.ruleId === 'estiva/no-copied-look'))
|
|
140
|
+
return copies.length ? { code: 0, message: '', note: copies.map((m) => `${relative(top, file).split(sep).join('/')}:${m.line} ${m.message}`).join('\n') } : PASS
|
|
141
|
+
}
|
|
104
142
|
|
|
105
143
|
const shown = (p: string) => relative(top, p).split(sep).join('/')
|
|
106
144
|
const lines = [`${shown(file)} was not written: the UI Guardrails refuse it (${shown(config)}).`]
|
|
107
145
|
for (const m of errors) lines.push(` ${m.line}:${m.column} ${m.message}${m.ruleId ? ` (${m.ruleId})` : ''}`)
|
|
108
146
|
const keep = audience === 'package' ? 'Keep it only' : 'Keep the element only'
|
|
109
|
-
for (const d of disabled) lines.push(` ${d.line} an eslint-disable switches ${d.ruleId} off. ${keep} with its reason on the line above: // @estiva-escape: <reason>`)
|
|
147
|
+
for (const d of disabled) lines.push(` ${d.line} an eslint-disable switches ${d.ruleId} off. ${keep} with its reason on the line above: // @estiva-escape(${d.ruleId.slice(d.ruleId.lastIndexOf('/') + 1)}): <reason>`)
|
|
110
148
|
return { code: 2, message: `${lines.join('\n')}\n` }
|
|
111
149
|
}
|
|
112
150
|
|
package/src/gates/index.ts
CHANGED
|
@@ -11,21 +11,25 @@
|
|
|
11
11
|
* - `tokenConfig` — the token lint on its own, as `lint:tokens` runs it (UIG-37).
|
|
12
12
|
* - `gateLint`, `gateConfig` — where the rules apply, and the gate that runs them alone.
|
|
13
13
|
* - `writeGateCount` — `.gates-count.json` (seam S3).
|
|
14
|
+
* - `runCi` — CI's job `gate`, every step of it (R13).
|
|
14
15
|
* - `runHook` — the editor gate a `PreToolUse` hook runs.
|
|
15
16
|
* - `runStatus`, `helpers` — gates:status and the helpers a checks file is written with.
|
|
16
17
|
*
|
|
17
18
|
* - `appChecks` — the gate checks every app runs, for its `scripts/gates-checks.mjs`.
|
|
18
19
|
* - `appFiles`, `createApp`, `themes` — a new app with every gate on (`create-estiva-app`).
|
|
19
20
|
*
|
|
20
|
-
* `estiva-gates` (`cli.ts`) runs the count, the hook and the status as one command.
|
|
21
|
+
* `estiva-gates` (`cli.ts`) runs the gate's CI, the count, the hook and the status as one command.
|
|
21
22
|
*
|
|
22
23
|
* Built by build.mjs into `dist/gates/`, for Node. Nothing here reaches the
|
|
23
24
|
* components' browser bundle.
|
|
24
25
|
*/
|
|
25
26
|
export { TOKEN_LINT_IGNORES, tokenConfig, tokenLint, tokenValues, type TokenAudience, type TokenConfigOptions, type TokenLintOptions } from './token-lint'
|
|
26
27
|
export { gateConfig, gateLint, type GateConfigOptions } from './gate-config'
|
|
28
|
+
export { countCommitted, hookRuns, runCi, skippedFolders, skipsListed, type CiOptions } from './ci'
|
|
27
29
|
export { writeGateCount, type CountOptions, type CountResult } from './count'
|
|
28
30
|
export { runHook, type HookOptions, type HookResult } from './hook'
|
|
31
|
+
export { nameEscapes, type NameEscapesOptions, type NameEscapesResult } from './name-escapes'
|
|
32
|
+
export { escapeLines, listEscapes, REVIEW_AFTER_DAYS, type Escape } from './escapes'
|
|
29
33
|
export { ENGINE, helpers, runStatus, type CheckResult, type GateCheck, type GateHelpers, type GateSpec, type GateTicket, type LintProbe, type StatusOptions, type TicketListEntry } from './status'
|
|
30
34
|
export { APP_TICKET_TITLES, appChecks, type AppCheckOptions } from './app-checks'
|
|
31
35
|
export { appFiles, askNpm, createApp, themes, type CreateAppOptions } from './create-app'
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* `estiva-gates name-escapes`: write into every escape marker the rules it keeps
|
|
4
|
+
* off, once (Katerina's ruling B3, 25 September).
|
|
5
|
+
*
|
|
6
|
+
* A marker used to keep off every rule on its element; now it keeps off only the
|
|
7
|
+
* rules it names, `// @estiva-escape(no-raw-element): <reason>`. So that nothing
|
|
8
|
+
* that was escaped breaks, this asks the gate itself which rules each unnamed
|
|
9
|
+
* marker stands for: linted with the new reader, an unnamed marker is reported
|
|
10
|
+
* once per rule that finds its element ("Name the rule … `@estiva-escape(<rule>)`"),
|
|
11
|
+
* and those names are written into the marker. Nothing is guessed, and a marker
|
|
12
|
+
* no rule reaches is left as it is and listed.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { createRequire } from 'node:module'
|
|
16
|
+
import { join, relative } from 'node:path'
|
|
17
|
+
import { pathToFileURL } from 'node:url'
|
|
18
|
+
import type { ESLint as ESLintClass } from 'eslint'
|
|
19
|
+
|
|
20
|
+
export interface NameEscapesOptions {
|
|
21
|
+
/** The folder the gate lints from. */
|
|
22
|
+
root?: string
|
|
23
|
+
/** The gate's config file, relative to `root`. */
|
|
24
|
+
config?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface NameEscapesResult {
|
|
28
|
+
/** One line per marker rewritten: `file:line rule, rule`. */
|
|
29
|
+
named: string[]
|
|
30
|
+
/** Markers no rule reached, left unnamed. */
|
|
31
|
+
unreached: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const UNNAMED = /@estiva-escape(?!\s*\()/
|
|
35
|
+
const NAMED_IN_MESSAGE = /@estiva-escape\(([^)]+)\)/
|
|
36
|
+
|
|
37
|
+
export async function nameEscapes({ root = process.cwd(), config = 'eslint.gates.config.js' }: NameEscapesOptions = {}): Promise<NameEscapesResult> {
|
|
38
|
+
const resolved = createRequire(join(root, 'package.json')).resolve('eslint')
|
|
39
|
+
const { ESLint } = (await import(pathToFileURL(resolved).href)) as { ESLint: typeof ESLintClass }
|
|
40
|
+
const eslint = new ESLint({ cwd: root, overrideConfigFile: join(root, config) })
|
|
41
|
+
const results = await eslint.lintFiles(['.'])
|
|
42
|
+
|
|
43
|
+
const named: string[] = []
|
|
44
|
+
const unreached: string[] = []
|
|
45
|
+
for (const result of results) {
|
|
46
|
+
// The rules each unnamed marker stands for, by the line its comment starts on.
|
|
47
|
+
const byLine = new Map<number, Set<string>>()
|
|
48
|
+
for (const m of result.messages) {
|
|
49
|
+
if (m.messageId !== 'escapeWithoutRule') continue
|
|
50
|
+
const rule = NAMED_IN_MESSAGE.exec(m.message)?.[1]
|
|
51
|
+
if (!rule) continue
|
|
52
|
+
byLine.set(m.line, (byLine.get(m.line) ?? new Set()).add(rule))
|
|
53
|
+
}
|
|
54
|
+
const source = result.source ?? readFileSync(result.filePath, 'utf8')
|
|
55
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n'
|
|
56
|
+
const lines = source.split(/\r?\n/)
|
|
57
|
+
let changed = false
|
|
58
|
+
const file = relative(root, result.filePath).split('\\').join('/')
|
|
59
|
+
for (const [line, rules] of byLine) {
|
|
60
|
+
// The marker is on the comment's first line, or within the next few (a block comment).
|
|
61
|
+
for (let i = line - 1; i < Math.min(lines.length, line + 3); i++) {
|
|
62
|
+
if (!UNNAMED.test(lines[i])) continue
|
|
63
|
+
const names = [...rules].sort().join(', ')
|
|
64
|
+
lines[i] = lines[i].replace(UNNAMED, `@estiva-escape(${names})`)
|
|
65
|
+
named.push(`${file}:${i + 1} ${names}`)
|
|
66
|
+
changed = true
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (changed) writeFileSync(result.filePath, lines.join(eol), 'utf8')
|
|
71
|
+
// Unnamed markers the gate did not report: nothing of the gate's reaches that element.
|
|
72
|
+
lines.forEach((text, i) => {
|
|
73
|
+
if (UNNAMED.test(text) && !/eslint-disable/.test(text) && /\/\/|\/\*/.test(text)) unreached.push(`${file}:${i + 1}`)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
return { named, unreached }
|
|
77
|
+
}
|