@weft-js/cli 0.1.1 → 0.1.2

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.
Files changed (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
@@ -11,187 +11,12 @@
11
11
 
12
12
  import { existsSync, readFileSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
- import { diagnostic, positions } from './diag.mjs'
15
- import { discover } from './files.mjs'
16
- import { scanJsx } from './jsx-scan.mjs'
14
+ import { makeReporter } from './design-checks/reporter.mjs'
15
+ import { checkJsx } from './design-checks/jsx.mjs'
16
+ import { checkCsp } from './design-checks/csp.mjs'
17
+ import { checkFiles } from './design-checks/files.mjs'
18
+ import { roleDrift } from './design-checks/rules.mjs'
17
19
 
18
- /** warp's spacing scale at the default density, px → token (warp.css). */
19
- const SPACE = new Map([[2, 'hair'], [4, 'tight'], [8, 'related'], [16, 'group']])
20
- const SPACE_NAMES = 'hair tight related group section page'.split(' ')
21
- /** warp's radii at the quiet stop, where a raw radius is most likely to have been copied from. */
22
- const RADIUS = new Map([[4, 'mark'], [8, 'control'], [12, 'surface']])
23
- const WARP = '@weft-js/warp'
24
- /** The role each heading level takes by default: the same table as warp's `Heading` (packages/warp/src/Type.tsx). */
25
- const HEADING_ROLE = { 1: 'title', 2: 'heading', 3: 'heading', 4: 'label', 5: 'label', 6: 'label' }
26
-
27
- const COLOR_EXPECTED = 'var(--warp-color-text), -text-subtle, -surface, -surface-raised, -accent-solid, -border, or a `tone` on the component'
28
- const COLOR = /#[0-9a-f]{3,8}\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(|(?<![-\w])(?:white|black|red|green|blue|gray|grey|orange|yellow|purple|pink|indigo|teal)(?![-\w])/i
29
- const COLOR_PROPS = /^(color|background|background-color|border-color|border(-(top|right|bottom|left|block|inline)(-(start|end))?)?|outline-color|fill|stroke|box-shadow|caret-color|accent-color)$/
30
- const SPACE_PROPS = /^(gap|row-gap|column-gap|padding|margin)(-(top|right|bottom|left|block|inline)(-(start|end))?)?$/
31
- const TYPE_PROPS = /^(font-size|font-weight|line-height|letter-spacing)$/
32
-
33
- const kebab = s => s.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)
34
-
35
- /** `16px 8px` → the same with tokens, or null if any part isn't exactly on the scale. */
36
- function scaled(value) {
37
- const parts = value.trim().split(/\s+/)
38
- const out = parts.map(p => {
39
- if (p === '0' || p === '0px' || p === 'auto') return p
40
- const m = /^(\d+(?:\.\d+)?)(px|rem)?$/.exec(p)
41
- if (!m) return null
42
- const px = m[2] === 'rem' ? Number(m[1]) * 16 : Number(m[1])
43
- return SPACE.has(px) ? `var(--warp-space-${SPACE.get(px)})` : null
44
- })
45
- return out.every(Boolean) ? out.join(' ') : null
46
- }
47
-
48
- /** Declarations in a `style` attribute: `{ prop, value, numeric, start, end }` with offsets into the file. */
49
- function styleDecls(attr) {
50
- if (attr.kind === 'string') {
51
- const at = attr.valueStart + 1
52
- return [...attr.value.matchAll(/([\w-]+)\s*:\s*([^;]+)/g)].map(m => {
53
- const start = at + m.index + m[0].indexOf(m[2], m[1].length + 1)
54
- return { prop: m[1].toLowerCase(), value: m[2].trim(), start, end: start + m[2].trimEnd().length }
55
- })
56
- }
57
- const out = []
58
- for (const m of (attr.expr ?? '').matchAll(/([\w$]+|['"][\w-]+['"])\s*:\s*(?:(['"`])([^'"`$]*)\2|(\d+(?:\.\d+)?)(?=\s*[,}\s]))/g)) {
59
- const value = m[3] ?? m[4]
60
- const inner = m[0].lastIndexOf(value)
61
- const start = attr.valueStart + m.index + inner - (m[3] !== undefined ? 1 : 0)
62
- out.push({ prop: kebab(m[1].replace(/['"]/g, '')), value, numeric: m[3] === undefined, start, end: start + value.length + (m[3] !== undefined ? 2 : 0), quote: m[2] })
63
- }
64
- return out
65
- }
66
-
67
- /** The D-code for one raw declaration, or null. `report` takes (code, start, end, why, fix). */
68
- function declaration({ prop, value, numeric, start, end, quote }, report) {
69
- const css = (numeric && SPACE_PROPS.test(prop) ? `${value}px` : value).replace(/\s*!important\s*$/i, '')
70
- if (/var\(|^(inherit|initial|unset|transparent|currentcolor|none)$/i.test(css.trim())) return
71
- if (COLOR_PROPS.test(prop) && COLOR.test(css)) return report('D001', start, end, `Raw colour \`${prop}: ${css}\`: it won't follow the theme, the dials or dark mode. Use ${COLOR_EXPECTED}.`)
72
- if (SPACE_PROPS.test(prop)) {
73
- if (/^(0(px)?|auto)$/.test(css.trim())) return
74
- const token = scaled(css)
75
- const text = token && (quote ? `${quote}${token}${quote}` : numeric ? `'${token}'` : token)
76
- return report('D002', start, end, `\`${prop}: ${css}\` is ${token ? 'a scale step written by hand' : 'off the spacing scale'}: gaps are relationships (${SPACE_NAMES.join(', ')}).`, token ? { description: `Use ${token}`, safe: true, text } : undefined)
77
- }
78
- if (TYPE_PROPS.test(prop)) return report('D003', start, end, `Raw \`${prop}: ${css}\`: type comes from roles, not sizes. Use \`<Heading role>\` or \`<Text role>\` (display title heading body label caption eyebrow code).`)
79
- if (prop === 'border-radius') {
80
- const m = /^(\d+)(?:px)?$/.exec(css.trim())
81
- const name = m && RADIUS.get(Number(m[1]))
82
- const text = name && `var(--warp-radius-${name})`
83
- return report('D003', start, end, `Raw \`border-radius: ${css}\`: radii come from the expression dial (mark, control, surface, round).`, name ? { description: `Use ${text}`, safe: true, text: quote ? `${quote}${text}${quote}` : numeric ? `'${text}'` : text } : undefined)
84
- }
85
- if (prop === 'box-shadow' && !/^none$/i.test(css.trim())) return report('D003', start, end, 'A raw shadow: elevation comes from `--warp-elevation-0…3`, which follow the dials and the theme.')
86
- }
87
-
88
- // -- Tailwind classes, for an app that has warp's tokens too --
89
-
90
- const TW_SPACE = new Map([['0.5', 'hair'], ['1', 'tight'], ['2', 'related'], ['4', 'group']])
91
- const TW_PALETTE = 'slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose'
92
- const TW_PX = 4
93
-
94
- /** D001–D003 for one class; returns `[code, why, replacement?]` or null. */
95
- function utility(cls) {
96
- const bare = cls.slice(cls.lastIndexOf(':') + 1).replace(/^-/, '').replace(/!$/, '')
97
- const variant = cls.slice(0, cls.length - cls.slice(cls.lastIndexOf(':') + 1).length)
98
- const space = /^(p|px|py|pt|pr|pb|pl|ps|pe|m|mx|my|mt|mr|mb|ml|ms|me|gap|gap-x|gap-y|space-x|space-y)-(.+)$/.exec(bare)
99
- if (space) {
100
- const [, util, v] = space
101
- if (['auto', 'px', '0'].includes(v) || SPACE_NAMES.includes(v)) return null
102
- const arb = /^\[(.+)\]$/.exec(v)?.[1]
103
- const px = arb ? (/^(\d+(?:\.\d+)?)(px|rem)$/.exec(arb) ?? [])[1] * (arb.endsWith('rem') ? 16 : 1) : Number(v) * TW_PX
104
- const name = arb ? SPACE.get(px) : TW_SPACE.get(v)
105
- if (arb || !Number.isNaN(Number(v))) return ['D002', `\`${cls}\` is ${name ? 'a scale step under another name' : 'off the spacing scale'}.`, name && `${variant}${util}-${name}`]
106
- return null
107
- }
108
- if (new RegExp(`^(bg|text|border|ring|fill|stroke|from|to|via|divide|outline|decoration|accent|caret|shadow)-(${TW_PALETTE})-\\d{2,3}(/\\d+)?$`).test(bare) || /^(bg|text|border|ring|fill|stroke)-(white|black)$/.test(bare))
109
- return ['D001', `\`${cls}\` is a raw palette colour: it won't follow the theme or the dials. Use \`bg-surface\`, \`text-text\`, \`text-text-subtle\`, \`bg-accent\`, \`border-border\`.`]
110
- if (/^(bg|text|border|ring|fill|stroke|from|to|via|outline)-\[(#|rgb|hsl|oklch)/.test(bare)) return ['D001', `\`${cls}\` is a raw colour: it won't follow the theme or the dials.`]
111
- const radius = /^rounded(-[a-z]+)?-\[(\d+)px\]$/.exec(bare)
112
- if (radius) return ['D003', `\`${cls}\` is a raw radius.`, RADIUS.has(Number(radius[2])) && !radius[1] ? `${variant}rounded-${RADIUS.get(Number(radius[2]))}` : undefined]
113
- if (/^(text|leading|tracking|font|shadow)-\[/.test(bare) || /^(text-(xs|sm|base|lg|[2-9]?xl)|font-(thin|light|normal|medium|semibold|bold|extrabold|black))$/.test(bare)) return ['D003', `\`${cls}\`: type and elevation come from roles, not sizes. Use \`<Heading role>\` or \`<Text role>\`.`]
114
- return null
115
- }
116
-
117
- /** `<div class="flex flex-col gap-4">` and `style={{ display: 'flex', … }}`: the primitive it spells out, or null. */
118
- function layoutOf(el) {
119
- const cls = el.attr('class') ?? el.attr('className')
120
- if (cls?.kind === 'string') {
121
- const c = cls.value.split(/\s+/).filter(Boolean)
122
- if (!c.includes('flex') && !c.includes('grid')) return null
123
- const dir = c.includes('flex-col') ? 'Stack' : c.includes('flex') ? 'Row' : null
124
- if (!dir) return null
125
- const gap = c.find(x => x.startsWith('gap-'))?.slice(4)
126
- const name = SPACE_NAMES.includes(gap) ? gap : TW_SPACE.get(gap)
127
- return { primitive: dir, gap: name, attr: cls, exact: c.every(x => ['flex', 'flex-col', 'flex-row', 'flex-wrap'].includes(x) || x.startsWith('gap-')) && (!gap || !!name) }
128
- }
129
- const style = el.attr('style')
130
- if (!style) return null
131
- const decls = styleDecls(style)
132
- if (!decls.some(d => d.prop === 'display' && d.value === 'flex')) return null
133
- const dir = decls.find(d => d.prop === 'flex-direction')?.value
134
- const gap = decls.find(d => d.prop === 'gap')
135
- const px = gap && Number(gap.value.replace('px', ''))
136
- const name = gap && SPACE.get(px)
137
- const only = decls.every(d => ['display', 'flex-direction', 'gap'].includes(d.prop))
138
- return { primitive: dir === 'column' ? 'Stack' : 'Row', gap: name, attr: style, exact: only && (!gap || !!name) && style.kind === 'expr' && !/\.\.\./.test(style.expr) && (dir === 'column' || dir === undefined ? true : dir === 'row') }
139
- }
140
-
141
- const NATIVE = { button: 'Button', select: 'Select', textarea: 'Textarea' }
142
- // the input types a browser paints, and the warp control for each; `Input` passes `type` through, so it counts too
143
- const NATIVE_TYPES = { date: 'DateField', 'datetime-local': 'DateTimeField', time: 'TimeField', month: 'MonthPicker', week: 'DateField', number: 'NumberField', range: 'Slider', color: 'ColorField', checkbox: 'Checkbox', radio: 'Radio', file: 'a dropzone' }
144
- const INTERACTIVE = new Set(['a', 'button', 'input', 'select', 'textarea', 'summary', 'Button', 'Toggle', 'Input', 'Search', 'Textarea', 'Select', 'Checkbox', 'Switch', 'Chip', 'Link'])
145
- const STATIC = new Set(['div', 'span', 'p', 'li', 'section', 'article', 'td', 'tr', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'label'])
146
-
147
- const NAMED = ['aria-label', 'aria-labelledby', 'title']
148
- /** Elements whose implicit role (generic, paragraph, emphasis…) may not be named, and the explicit roles that can't be either. */
149
- const GENERIC = new Set(['div', 'span', 'p', 'b', 'i', 'em', 'strong', 'small', 'code', 's', 'u', 'sub', 'sup', 'mark', 'del', 'ins', 'kbd', 'samp', 'var'])
150
- const UNNAMED = new Set(['generic', 'presentation', 'none', 'paragraph', 'caption', 'code', 'deletion', 'emphasis', 'insertion', 'strong', 'subscript', 'superscript', 'mark', 'time'])
151
- const named = el => NAMED.some(n => el.attr(n)) || el.attrs.some(a => a.kind === 'spread')
152
- /** Children with something a screen reader can read: text, an expression, a component, an image with alt. */
153
- const hasName = children => {
154
- if (/<img\b[^>]*\balt=(["'])[^"']+\1/.test(children)) return true
155
- const stripped = children.replace(/<svg[\s\S]*?<\/svg>/g, '').replace(/<(Icon|[A-Z]\w*Icon)\b[^>]*\/>/g, '').replace(/<[^>]*aria-hidden[^>]*>[\s\S]*?<\/[^>]+>/g, '')
156
- return /[^\s<>{}]/.test(stripped.replace(/<\/?[a-z][^>]*>/g, ''))
157
- }
158
-
159
- /** Parse `#rgb`, `#rrggbb`, `rgb(r, g, b)`, `white` and `black`, or null. */
160
- function literalColor(v) {
161
- const s = v.trim().toLowerCase()
162
- if (s === 'white') return [255, 255, 255]
163
- if (s === 'black') return [0, 0, 0]
164
- const h = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(s)?.[1]
165
- if (h) return (h.length === 3 ? Array.from(h, c => c + c) : h.match(/../g)).map(x => parseInt(x, 16))
166
- const m = /^rgb\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)\s*\)$/.exec(s)
167
- return m ? m.slice(1).map(Number) : null
168
- }
169
- const lum = c => { const [r, g, b] = c.map(x => { const s = x / 255; return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }); return 0.2126 * r + 0.7152 * g + 0.0722 * b }
170
- const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05) }
171
-
172
- /** Lines an allow comment covers: `// weft-allow D001: reason` (or `{/* … *\/}`) on the line above or at the line's end. */
173
- function allowed(text) {
174
- const out = new Map()
175
- text.split('\n').forEach((line, i) => {
176
- const m = /weft-allow\s+((?:D\d{3}[\s,]*)+):\s*\S/.exec(line)
177
- if (!m) return
178
- for (const code of m[1].match(/D\d{3}/g)) for (const n of [i + 1, i + 2]) out.set(`${code}:${n}`, true)
179
- })
180
- return out
181
- }
182
-
183
- /** The edit that imports `names` from warp: into the file's existing import, or a new first line. */
184
- function addImport(text, names) {
185
- const m = new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${WARP}['"]`).exec(text)
186
- if (!m) return [{ start: 0, end: 0, text: `import { ${names.join(', ')} } from '${WARP}'\n` }]
187
- const have = m[1].split(',').map(s => s.trim()).filter(Boolean)
188
- const missing = names.filter(n => !have.includes(n))
189
- if (!missing.length) return []
190
- const at = m.index + m[0].indexOf(m[1]) + m[1].trimEnd().length
191
- return [{ start: at, end: at, text: `, ${missing.join(', ')}` }]
192
- }
193
-
194
- /** Does the app have warp's tokens? */
195
20
  export function hasTokens(root) {
196
21
  const file = join(root, 'package.json')
197
22
  if (!existsSync(file)) return false
@@ -208,232 +33,17 @@ export function hasTokens(root) {
208
33
  * @param {{ files: { rel: string, text: string }[], exclude?: string[], tokens: boolean }} options
209
34
  */
210
35
  export function designChecks(root, { files: all, exclude = [], tokens }) {
211
- const files = all.filter(f => !/\.og\.[jt]sx$/.test(f.rel)) // a share card is drawn to a PNG: no theme, no tokens, no reader
36
+ const files = all.filter((f) => !/\.og\.[jt]sx$/.test(f.rel)) // a share card is drawn to a PNG: no theme, no tokens, no reader
212
37
  const out = []
213
38
  const looks = new Map() // heading level → every site that renders it, across the app, for D006
214
- const reporter = file => {
215
- const pos = positions(file.text)
216
- const allow = allowed(file.text)
217
- return (code, start, end, why, fix, severity) => {
218
- if (allow.has(`${code}:${pos.lineCol(start)[0]}`)) return
219
- const edits = fix && (fix.edits ?? [{ start: fix.at?.[0] ?? start, end: fix.at?.[1] ?? end, text: fix.text }]).map(e => ({ file: file.rel, span: pos.span(e.start, e.end), offset: [e.start, e.end], text: e.text }))
220
- out.push(diagnostic({ code, severity, file: file.rel, span: pos.span(start, end), offset: [start, end], why, fix: fix && { description: fix.description, safe: fix.safe, edits } }))
221
- }
222
- }
223
-
224
- for (const f of files) {
225
- if (!/\.[jt]sx$/.test(f.rel)) continue
226
- let scan
227
- try {
228
- scan = scanJsx(f.text, f.rel)
229
- } catch {
230
- continue // syntax errors are W000's
231
- }
232
- const report = reporter(f)
233
- const localComponents = new Set(scan.functions.keys())
234
- const warpName = n => scan.imports.some(i => i.source === WARP && i.local === n)
235
- const labelled = new Set(scan.elements.map(e => e.attr('for')?.value ?? e.attr('htmlFor')?.value).filter(Boolean))
236
- const headings = []
237
- const solids = new Map()
238
-
239
- for (const el of scan.elements) {
240
- // -- values --
241
- if (tokens) {
242
- const style = el.attr('style')
243
- if (style) for (const d of styleDecls(style)) declaration(d, report)
244
- const cls = el.attr('class') ?? el.attr('className')
245
- if (cls?.kind === 'string') {
246
- let at = cls.valueStart + 1
247
- for (const c of cls.value.split(/(\s+)/)) {
248
- const hit = c.trim() && utility(c)
249
- if (hit) report(hit[0], at, at + c.length, hit[1], hit[2] ? { description: `Use ${hit[2]}`, safe: true, text: hit[2] } : undefined)
250
- at += c.length
251
- }
252
- }
253
- const layout = /^[a-z]/.test(el.name) && ['div', 'section', 'ul', 'ol', 'nav', 'header', 'footer', 'main', 'aside', 'article', 'form'].includes(el.name) && layoutOf(el)
254
- if (layout) {
255
- const close = f.text.lastIndexOf('</', el.end - 1)
256
- const selfClosed = !f.text.slice(el.start, el.end).includes(`</${el.name}`)
257
- const as = el.name === 'div' ? '' : ` as="${el.name}"`
258
- const gap = layout.gap ? ` gap="${layout.gap}"` : ''
259
- const sole = el.attrs.length === 1
260
- const fix = layout.exact && sole && !selfClosed && {
261
- description: `Use <${layout.primitive}>`, safe: false,
262
- edits: [
263
- { start: el.start + 1, end: el.attrs[0].end, text: `${layout.primitive}${as}${gap}` },
264
- { start: close + 2, end: el.end - 1, text: layout.primitive },
265
- ...addImport(f.text, [layout.primitive]),
266
- ].sort((a, b) => b.start - a.start),
267
- }
268
- report('D004', el.start, el.tagEnd, `Hand-rolled layout on <${el.name}>: <${layout.primitive}> says what this is and keeps the gap on the scale.`, fix || undefined)
269
- }
270
- const bare = /^h[1-6]$/.test(el.name)
271
- if (bare || (el.name === 'Heading' && warpName('Heading'))) {
272
- const site = headingSite(el, bare)
273
- if (site && !allowed(f.text).has(`D006:${positions(f.text).lineCol(el.start)[0]}`)) looks.set(site.level, [...(looks.get(site.level) ?? []), { ...site, el, text: f.text, report }])
274
- }
275
- if (el.attr('data-weft')?.value === 'styled' && (/^(?:main|h1|article)$/.test(el.name) || el.attr('role')?.value === 'main' || el.attr('data-vt')?.value === 'main')) {
276
- report('D040', el.start, el.tagEnd, `The style gate on <${el.name}>: this is the page's main content, and the first paint would leave it hidden until the stylesheet lands.`, 'Mark a secondary part, or remove `data-weft="styled"`')
277
- }
278
- if (el.name === 'a' && el.attr('href') && !el.attr('class') && !el.attr('className') && !el.attr('style') && !el.attr('data-scope') && !el.attr('data-part') && !el.attrs.some(a => a.kind === 'spread')) {
279
- const taken = localComponents.has('Link') || scan.imports.some(i => i.local === 'Link' && i.source !== WARP)
280
- const close = f.text.lastIndexOf('</', el.end - 1)
281
- report('D007', el.start, el.tagEnd, 'A bare <a> in a warp app: it gets warp\'s link colour but no underline sweep or touch target, and nav links read as prose. Use <Link>, or `Shell`\'s `nav` for navigation.', !taken && f.text.slice(el.start, el.end).includes('</a') ? {
282
- description: 'Use <Link>', safe: false,
283
- edits: [{ start: el.start + 1, end: el.tagEnd, text: 'Link' }, { start: close + 2, end: el.end - 1, text: 'Link' }, ...addImport(f.text, ['Link'])].sort((a, b) => b.start - a.start),
284
- } : undefined)
285
- }
286
- const typed = (el.name === 'input' || el.name === 'Input') && NATIVE_TYPES[el.attr('type')?.value]
287
- if (typed && !el.attr('hidden') && !el.attr('data-part')) report('D005', el.start, el.tagEnd, `\`type="${el.attr('type').value}"\` is painted by the browser: <${typed}> is warp's.`)
288
- if (/^[a-z]/.test(el.name) && el.name !== 'iframe' && el.attr('title')) report('D005', el.start, el.tagEnd, 'A `title` tooltip: the browser paints it, late, and never on touch or keyboard. Use <Tooltip>, or `aria-label` for a name.')
289
- if (NATIVE[el.name] && !localComponents.has(NATIVE[el.name])) {
290
- const swap = el.name === 'button' && !el.attr('class') && !el.attr('className') && !el.attr('style') && f.text.slice(el.start, el.end).includes('</button')
291
- report('D005', el.start, el.tagEnd, `Native <${el.name}> in a warp app: <${NATIVE[el.name]}> brings the focus ring, the sweep, the tones and the touch target.`, swap ? {
292
- description: 'Use <Button>', safe: false,
293
- edits: [{ start: el.start + 1, end: el.tagEnd, text: 'Button' }, { start: f.text.lastIndexOf('</', el.end - 1) + 2, end: el.end - 1, text: 'Button' }, ...addImport(f.text, ['Button'])].sort((a, b) => b.start - a.start),
294
- } : undefined)
295
- }
296
- }
297
-
298
- // -- contrast of a literal pair --
299
- const style = el.attr('style')
300
- if (style) {
301
- const decls = styleDecls(style)
302
- const fg = decls.find(d => d.prop === 'color')
303
- const bg = decls.find(d => d.prop === 'background' || d.prop === 'background-color')
304
- const [a, b] = [fg && literalColor(fg.value), bg && literalColor(bg.value)]
305
- if (a && b && ratio(a, b) < 4.5) report('D011', style.start, style.end, `\`${fg.value}\` on \`${bg.value}\` is ${ratio(a, b).toFixed(2)}:1; body text needs 4.5:1 (WCAG 1.4.3).`)
306
- const none = decls.find(d => (d.prop === 'outline' || d.prop === 'outline-width') && /^(none|0(px)?)$/.test(d.value))
307
- if (none) report('D036', none.start, none.end, 'This removes the focus ring with nothing in its place: keyboard users lose their place (WCAG 2.4.7).')
308
- }
309
- const cls = el.attr('class') ?? el.attr('className')
310
- if (cls?.kind === 'string' && /(^|\s)(focus:)?outline-none(\s|$)/.test(cls.value) && !/focus(-visible)?:(ring|outline(?!-none))/.test(cls.value)) report('D036', cls.start, cls.end, '`outline-none` with no `focus-visible:` style in its place: keyboard users lose their place (WCAG 2.4.7).')
311
-
312
- // -- accessible names --
313
- if (['input', 'select', 'textarea'].includes(el.name)) {
314
- const type = el.attr('type')?.value
315
- const skip = ['hidden', 'submit', 'button', 'reset', 'image'].includes(type)
316
- const id = el.attr('id')
317
- // A computed id can pair with a label anywhere, a component around the field can render its label, and an
318
- // aria-hidden field (a honeypot) has no name to give. `for` counts on a component too: a `<Field for>` passes it on.
319
- const unseen = id?.kind === 'expr' || el.attr('aria-hidden')?.value === 'true' || el.ancestors.some(a => /^[A-Z]/.test(a))
320
- if (!skip && !unseen && !named(el) && !(id?.value && labelled.has(id.value)) && !el.ancestors.includes('label')) report('D030', el.start, el.tagEnd, `<${el.name}> has no label: a screen reader announces only "edit" (WCAG 1.3.1, 4.1.2).${el.attr('placeholder') ? ' A placeholder is not a label.' : ''}`)
321
- }
322
- if (['button', 'a', 'Button', 'Link'].includes(el.name) && !el.attr('label') && !named(el) && !(el.name === 'a' && !el.attr('href')) && !hasName(el.children)) report('D031', el.start, el.tagEnd, `<${el.name}> has no name a screen reader can read: only an icon, or nothing (WCAG 4.1.2).`)
323
- const label = el.attr('aria-label') ?? el.attr('aria-labelledby')
324
- const role = el.attr('role')
325
- if (label && GENERIC.has(el.name) && (!role || (role.kind === 'string' && UNNAMED.has(role.value)))) report('D041', label.start, label.end, `\`${label.name}\` on a <${el.name}>${role ? ` with role "${role.value}"` : ''}: ARIA forbids naming it, so screen readers skip the name (axe aria-prohibited-attr, Lighthouse).`)
326
- if (el.name === 'img' && !el.attr('alt') && !el.attrs.some(a => a.kind === 'spread')) report('D032', el.start, el.tagEnd, '<img> has no `alt`: a screen reader reads out the file name (WCAG 1.1.1).')
327
-
328
- // -- behaviour on the wrong element --
329
- if (STATIC.has(el.name) && el.attr('onClick') && !el.attr('role') && !el.attr('tabindex') && !el.attr('tabIndex')) {
330
- const swap = (el.name === 'div' || el.name === 'span') && f.text.slice(el.start, el.end).includes(`</${el.name}`)
331
- report('D033', el.start, el.tagEnd, `onClick on <${el.name}>: no keyboard, no focus, no role, so it only works with a mouse (WCAG 2.1.1).`, swap ? {
332
- description: 'Use a real button', safe: false,
333
- edits: [{ start: el.start + 1, end: el.tagEnd, text: 'button type="button"' }, { start: f.text.lastIndexOf('</', el.end - 1) + 2, end: el.end - 1, text: 'button' }].sort((a, b) => b.start - a.start),
334
- } : undefined)
335
- }
336
- if (INTERACTIVE.has(el.name) && el.ancestors.some(a => INTERACTIVE.has(a) && a !== 'label')) {
337
- const parent = [...el.ancestors].reverse().find(a => INTERACTIVE.has(a))
338
- report('D034', el.start, el.tagEnd, `<${el.name}> inside <${parent}>: nested controls don't reach the keyboard and screen readers merge them.`)
339
- }
340
- const tabindex = el.attr('tabindex') ?? el.attr('tabIndex')
341
- const tab = tabindex?.kind === 'string' ? Number(tabindex.value) : tabindex?.kind === 'expr' ? Number(tabindex.expr) : 0
342
- if (tab > 0) report('D037', tabindex.start, tabindex.end, `\`tabindex="${tab}"\` jumps the page's tab order (WCAG 2.4.3).`, { description: 'Use tabindex 0', safe: true, text: tabindex.kind === 'string' ? '"0"' : '0', at: [tabindex.valueStart, tabindex.valueEnd] })
39
+ const reporter = makeReporter(out)
343
40
 
344
- // -- outline --
345
- const h = /^h([1-6])$/.exec(el.name)
346
- const level = h ? Number(h[1]) : el.name === 'Heading' && warpName('Heading') ? Number(el.attr('level')?.value ?? /^\d$/.exec(el.attr('level')?.expr ?? '')?.[0] ?? 2) : 0
347
- if (level) headings.push({ level, el })
348
-
349
- // -- one primary action per region --
350
- if (el.name === 'Button' && warpName('Button') && (!el.attr('emphasis') || el.attr('emphasis')?.value === 'solid') && !el.attr('tone')) {
351
- const region = el.ancestors.findLast(a => ['Card', 'Modal', 'Drawer', 'Popover', 'section', 'form', 'header', 'footer', 'main', 'aside', 'Alert'].includes(a)) ?? ''
352
- const key = `${region}:${el.ancestors.length}:${scanParent(el, scan.elements)?.start ?? 0}`
353
- solids.set(key, [...(solids.get(key) ?? []), el])
354
- }
355
- }
356
-
357
- for (let i = 1; i < headings.length; i++) {
358
- const { level, el } = headings[i]
359
- if (level > headings[i - 1].level + 1) report('D035', el.start, el.tagEnd, `The outline jumps from h${headings[i - 1].level} to h${level}: screen-reader users navigate by headings (WCAG 1.3.1).`, undefined, 'warning')
360
- }
361
- for (const group of solids.values()) {
362
- for (const el of group.slice(1)) report('D038', el.start, el.tagEnd, `${group.length} solid Buttons side by side: one primary action per region.`, { description: 'Make this one soft', safe: false, text: ' emphasis="soft"', at: [el.tagEnd, el.tagEnd] })
363
- }
364
- }
41
+ checkJsx({ files, reporter, tokens, looks })
365
42
 
366
43
  for (const [level, sites] of looks) roleDrift(level, sites)
367
44
 
368
- // -- CSS files --
369
- for (const rel of discover(root, { include: ['**/*.css'], exclude })) {
370
- const text = readFileSync(join(root, rel), 'utf8')
371
- const css = text.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '))
372
- const file = { rel, text }
373
- const report = reporter(file)
374
- for (const m of css.matchAll(/(?<=[{;\s])(-?-?[\w-]+)\s*:\s*([^;{}]+)/g)) {
375
- const prop = m[1].toLowerCase()
376
- if (prop.startsWith('--')) continue
377
- const start = m.index + m[0].indexOf(m[2], m[1].length + 1)
378
- if (tokens) declaration({ prop, value: m[2].trim(), start, end: start + m[2].trimEnd().length }, report)
379
- if ((prop === 'outline' || prop === 'outline-width') && /^(none|0(px)?)\s*$/.test(m[2])) {
380
- const rule = css.lastIndexOf('}', m.index)
381
- const selector = css.slice(rule + 1, css.indexOf('{', rule + 1))
382
- if (/:focus(?!-within)/.test(selector) && !/box-shadow|border/.test(css.slice(m.index, css.indexOf('}', m.index)))) report('D036', start, start + m[2].trimEnd().length, `\`${selector.trim()}\` removes the focus ring with nothing in its place: keyboard users lose their place (WCAG 2.4.7).`)
383
- }
384
- }
385
- }
45
+ checkCsp(root, files, reporter)
386
46
 
387
- // -- the page --
388
- for (const rel of discover(root, { include: ['**/*.html'], exclude })) {
389
- const text = readFileSync(join(root, rel), 'utf8')
390
- const m = /<html\b[^>]*>/i.exec(text)
391
- if (m && !/\blang\s*=/.test(m[0])) reporter({ rel, text })('D039', m.index, m.index + m[0].length, '<html> has no `lang`: a screen reader guesses the language to speak (WCAG 3.1.1).', { description: 'Set lang="en"', safe: false, text: `${m[0].slice(0, -1)} lang="en">` })
392
- }
47
+ checkFiles(root, exclude, tokens, reporter)
393
48
  return out
394
49
  }
395
-
396
- /** What one heading renders at: `{ level, role, set }`, or null when a `level` or `role` expression hides it. `role` is null for a bare `<hN>`. */
397
- function headingSite(el, bare) {
398
- if (bare) return { level: Number(el.name[1]), role: null }
399
- const lv = el.attr('level')
400
- const level = !lv ? 2 : Number(lv.value ?? /^\d$/.exec(lv.expr ?? '')?.[0])
401
- if (!HEADING_ROLE[level]) return null
402
- const attr = el.attr('role')
403
- if (attr && attr.kind !== 'string') return null
404
- return { level, role: attr?.value ?? HEADING_ROLE[level], set: attr }
405
- }
406
-
407
- /** D006: the roles one level renders at across the app. The most used `Heading` role wins, then the level's default; every other site is fixed to it. */
408
- function roleDrift(level, sites) {
409
- const counts = new Map()
410
- for (const s of sites) if (s.role) counts.set(s.role, (counts.get(s.role) ?? 0) + 1)
411
- if (!counts.size || new Set(sites.map(s => s.role)).size < 2) return
412
- const fallback = HEADING_ROLE[level]
413
- const top = Math.max(...counts.values())
414
- const role = counts.get(fallback) === top ? fallback : [...counts].find(([, n]) => n === top)[0]
415
- const prop = role === fallback ? '' : ` role="${role}"`
416
- const all = [...counts.keys()].join(', ')
417
- for (const { el, text, report, role: has, set } of sites) {
418
- if (has === role) continue
419
- if (has === null) {
420
- const swap = !el.attr('class') && !el.attr('className') && !el.attr('style') && text.slice(el.start, el.end).includes(`</${el.name}`)
421
- report('D006', el.start, el.tagEnd, `A bare <h${level}> beside <Heading level={${level}}>: this one renders at the browser's size, the rest at \`${role}\`.`, swap ? {
422
- description: `Use <Heading level={${level}}>`, safe: false,
423
- edits: [{ start: el.start + 1, end: el.tagEnd, text: `Heading level={${level}}${prop}` }, { start: text.lastIndexOf('</', el.end - 1) + 2, end: el.end - 1, text: 'Heading' }, ...addImport(text, ['Heading'])].sort((a, b) => b.start - a.start),
424
- } : undefined)
425
- continue
426
- }
427
- const fix = set
428
- ? { description: role === fallback ? `Drop \`role\`: h${level} is \`${role}\`` : `Use role="${role}"`, safe: false, edits: [role === fallback ? { start: text.slice(0, set.start).trimEnd().length, end: set.end, text: '' } : { start: set.valueStart, end: set.valueEnd, text: `"${role}"` }] }
429
- : { description: `Use role="${role}"`, safe: false, text: prop, at: [el.tagEnd, el.tagEnd] }
430
- report('D006', el.start, el.tagEnd, `h${level} renders at two roles in this app (${all}): a level is one size everywhere. This one is \`${has}\`, the rest \`${role}\`. \`// weft-allow D006: <reason>\` keeps a real exception.`, fix)
431
- }
432
- }
433
-
434
- /** The nearest element that contains `el`. */
435
- function scanParent(el, elements) {
436
- let best
437
- for (const e of elements) if (e !== el && e.start <= el.start && e.end >= el.end && (!best || e.start >= best.start)) best = e
438
- return best
439
- }
package/src/diag.mjs CHANGED
@@ -13,8 +13,8 @@ export function positions(text) {
13
13
  const ascii = bytes.length === text.length
14
14
  const lineStarts = [0]
15
15
  for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) lineStarts.push(i + 1)
16
- const fromByte = b => (ascii ? b : bytes.subarray(0, b).toString('utf8').length)
17
- const lineCol = index => {
16
+ const fromByte = (b) => (ascii ? b : bytes.subarray(0, b).toString('utf8').length)
17
+ const lineCol = (index) => {
18
18
  let lo = 0
19
19
  let hi = lineStarts.length - 1
20
20
  while (lo < hi) {
@@ -28,7 +28,18 @@ export function positions(text) {
28
28
  return { fromByte, lineCol, span }
29
29
  }
30
30
 
31
- export function diagnostic({ code, severity, file, span = [1, 1, 1, 1], offset, why, help, fix, related, owner = 'core' }) {
31
+ export function diagnostic({
32
+ code,
33
+ severity,
34
+ file,
35
+ span = [1, 1, 1, 1],
36
+ offset,
37
+ why,
38
+ help,
39
+ fix,
40
+ related,
41
+ owner = 'core',
42
+ }) {
32
43
  const known = entry(code)
33
44
  const d = { code, severity: severity ?? known?.severity ?? 'error', owner, file, span }
34
45
  if (offset) d.offset = offset
@@ -43,7 +54,11 @@ export function diagnostic({ code, severity, file, span = [1, 1, 1, 1], offset,
43
54
  export function locate(text, needle) {
44
55
  if (!needle || !text) return { span: [1, 1, 1, 1] }
45
56
  const esc = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
46
- for (const re of [new RegExp(`(?<![\\w$.-])${esc}(?=\\s*:)`), new RegExp(`(['"\`])${esc}\\1`), new RegExp(esc)]) {
57
+ for (const re of [
58
+ new RegExp(`(?<![\\w$.-])${esc}(?=\\s*:)`),
59
+ new RegExp(`(['"\`])${esc}\\1`),
60
+ new RegExp(esc),
61
+ ]) {
47
62
  const m = re.exec(text)
48
63
  if (!m) continue
49
64
  const at = m.index + (m[0].length - needle.length) / 2
@@ -56,19 +71,28 @@ export function summarize(diagnostics) {
56
71
  const byCode = {}
57
72
  for (const d of diagnostics) byCode[d.code] = (byCode[d.code] ?? 0) + 1
58
73
  return {
59
- errors: diagnostics.filter(d => d.severity === 'error').length,
60
- warnings: diagnostics.filter(d => d.severity === 'warning').length,
74
+ errors: diagnostics.filter((d) => d.severity === 'error').length,
75
+ warnings: diagnostics.filter((d) => d.severity === 'warning').length,
61
76
  byCode,
62
77
  }
63
78
  }
64
79
 
65
80
  export function sortDiagnostics(diagnostics) {
66
- return diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.span[0] - b.span[0] || a.span[1] - b.span[1] || a.code.localeCompare(b.code))
81
+ return diagnostics.sort(
82
+ (a, b) =>
83
+ a.file.localeCompare(b.file) ||
84
+ a.span[0] - b.span[0] ||
85
+ a.span[1] - b.span[1] ||
86
+ a.code.localeCompare(b.code),
87
+ )
67
88
  }
68
89
 
69
90
  /** Human text for stderr: `file:line:col CODE severity why` then the fix. */
70
91
  export function formatHuman(diagnostics) {
71
92
  return diagnostics
72
- .map(d => `${d.file}:${d.span[0]}:${d.span[1]} ${d.code} ${d.severity} ${d.why}\n fix: ${d.fix.text}${d.fix.edits ? ' (autofix)' : ''}`)
93
+ .map(
94
+ (d) =>
95
+ `${d.file}:${d.span[0]}:${d.span[1]} ${d.code} ${d.severity} ${d.why}\n fix: ${d.fix.text}${d.fix.edits ? ' (autofix)' : ''}`,
96
+ )
73
97
  .join('\n')
74
98
  }