@weft-js/cli 0.0.0-stage → 0.1.1

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 (91) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +74 -2
  3. package/bin/weft.mjs +8 -0
  4. package/docs/async.md +40 -0
  5. package/docs/browser.md +39 -0
  6. package/docs/config.md +93 -0
  7. package/docs/deploy.md +83 -0
  8. package/docs/design.md +97 -0
  9. package/docs/fonts.md +70 -0
  10. package/docs/icons.md +102 -0
  11. package/docs/lists.md +23 -0
  12. package/docs/meta.md +82 -0
  13. package/docs/plugins.md +143 -0
  14. package/docs/props.md +77 -0
  15. package/docs/rules.md +32 -0
  16. package/docs/rust.md +223 -0
  17. package/docs/start-short.md +8 -0
  18. package/docs/start.md +422 -0
  19. package/docs/state.md +96 -0
  20. package/docs/style.md +59 -0
  21. package/docs/surface.md +111 -0
  22. package/docs/testing.md +71 -0
  23. package/docs/ui-state.md +92 -0
  24. package/docs/verify.md +48 -0
  25. package/package.json +39 -4
  26. package/src/add-rust.mjs +123 -0
  27. package/src/add.mjs +358 -0
  28. package/src/agents-md.mjs +134 -0
  29. package/src/alias.mjs +59 -0
  30. package/src/audit.mjs +366 -0
  31. package/src/browser/cli.mjs +34 -0
  32. package/src/browser/client.mjs +132 -0
  33. package/src/browser/commands.mjs +143 -0
  34. package/src/catalog.mjs +496 -0
  35. package/src/check.mjs +330 -0
  36. package/src/cli.mjs +364 -0
  37. package/src/config.d.ts +1 -0
  38. package/src/config.mjs +5 -0
  39. package/src/contributions.mjs +69 -0
  40. package/src/db-layer.mjs +33 -0
  41. package/src/deploy/cdn.mjs +64 -0
  42. package/src/deploy/files.mjs +187 -0
  43. package/src/deploy/fit.mjs +62 -0
  44. package/src/deploy/hosts.mjs +56 -0
  45. package/src/deploy/index.mjs +273 -0
  46. package/src/deploy/needs.mjs +92 -0
  47. package/src/design-checks.mjs +439 -0
  48. package/src/diag.mjs +74 -0
  49. package/src/docs.mjs +132 -0
  50. package/src/doctor.mjs +30 -0
  51. package/src/files.mjs +47 -0
  52. package/src/fix.mjs +40 -0
  53. package/src/gate.mjs +276 -0
  54. package/src/index.mjs +9 -0
  55. package/src/infer/engine.cjs +525 -0
  56. package/src/infer/facts.cjs +252 -0
  57. package/src/infer/hosts.cjs +114 -0
  58. package/src/infer/lsp.mjs +131 -0
  59. package/src/infer/session.cjs +105 -0
  60. package/src/infer/shadow.cjs +46 -0
  61. package/src/infer/worker.cjs +97 -0
  62. package/src/jsx-scan.mjs +86 -0
  63. package/src/load-config.mjs +303 -0
  64. package/src/map.mjs +110 -0
  65. package/src/mcp.mjs +195 -0
  66. package/src/meta-table.mjs +28 -0
  67. package/src/mutate.mjs +140 -0
  68. package/src/plugin-checks.mjs +89 -0
  69. package/src/plugins.mjs +172 -0
  70. package/src/project.mjs +219 -0
  71. package/src/props-checks.mjs +46 -0
  72. package/src/props-listener.mjs +38 -0
  73. package/src/react-checks.mjs +36 -0
  74. package/src/share-checks.mjs +74 -0
  75. package/src/site.mjs +19 -0
  76. package/src/slots.mjs +96 -0
  77. package/src/start.mjs +296 -0
  78. package/src/state-checks.mjs +213 -0
  79. package/src/style.mjs +320 -0
  80. package/src/surface-checks.mjs +218 -0
  81. package/src/tailwind.mjs +22 -0
  82. package/src/tasks.mjs +99 -0
  83. package/src/testdiff.mjs +157 -0
  84. package/src/tests.mjs +572 -0
  85. package/src/text-checks.mjs +172 -0
  86. package/src/tokens.mjs +24 -0
  87. package/src/trust.mjs +282 -0
  88. package/src/weft.mjs +66 -0
  89. package/src/workspace.mjs +105 -0
  90. package/ts-plugin/index.js +137 -0
  91. package/ts-plugin/package.json +1 -0
@@ -0,0 +1,92 @@
1
+ // What an app needs from a host (research 46 §2), read from its files in one pass: no Vite server, no build. Each need
2
+ // carries the file that shows it, so a diagnostic can point there.
3
+
4
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
5
+ import { join, relative } from 'node:path'
6
+
7
+ const SKIP = new Set(['node_modules', '.weft', '.output', 'dist', '.netlify', '.vercel'])
8
+ /** npm packages with a compiled addon: one built on a Mac doesn't load on a Linux function. */
9
+ const ADDONS = /^(better-sqlite3|sqlite3|sharp|bcrypt|argon2|canvas|@node-rs\/.+|@napi-rs\/canvas|bufferutil|utf-8-validate)$/
10
+ /** A bus that reaches every instance. The memory bus is what's left when none is named. */
11
+ const BUSES = /\b(postgresBus|redisBus|natsBus|durableObjectBus)\s*\(|@weft-js\/realtime-(redis|nats|durable-object)/
12
+
13
+ const posix = p => p.replaceAll('\\', '/')
14
+
15
+ function sources(root) {
16
+ const out = []
17
+ const walk = dir => {
18
+ for (const d of readdirSync(dir, { withFileTypes: true })) {
19
+ const f = join(dir, d.name)
20
+ if (d.isDirectory()) { if (!SKIP.has(d.name)) walk(f) } else if (/\.[cm]?[jt]sx?$/.test(d.name) && !/\.(test|spec)\.|\.d\.ts$/.test(d.name)) out.push(f)
21
+ }
22
+ }
23
+ if (existsSync(join(root, 'src'))) walk(join(root, 'src'))
24
+ return out
25
+ }
26
+
27
+ /** `.weft/contract.json`, when it's newer than every source file: what the last build saw is then what's there. */
28
+ function freshContract(root, files) {
29
+ const file = join(root, '.weft', 'contract.json')
30
+ if (!existsSync(file)) return
31
+ const at = statSync(file).mtimeMs
32
+ if (files.some(f => statSync(f).mtimeMs > at)) return
33
+ try { return JSON.parse(readFileSync(file, 'utf8')) } catch {}
34
+ }
35
+
36
+ const read = file => { try { return readFileSync(file, 'utf8') } catch { return '' } }
37
+
38
+ /**
39
+ * @param {string} root
40
+ * @param {any} [config] the loaded weft.config
41
+ * @returns {{ sockets?: Need, required?: Need, feed?: Need, memoryBus?: Need, sidecar?: Need, native?: Need, sqlite?: Need, uploads?: Need, addons?: Need, runtime: string }}
42
+ * @typedef {{ file: string, what: string }} Need
43
+ */
44
+ export function needsOf(root, config = {}) {
45
+ const files = sources(root)
46
+ const rel = f => posix(relative(root, f))
47
+ const needs = { runtime: config.runtime ?? 'node' }
48
+ const contract = freshContract(root, files)
49
+ const realtimeFile = join(root, 'src/realtime.server.ts')
50
+ const realtime = read(realtimeFile)
51
+ let streams = false
52
+ if (contract) {
53
+ const live = contract.functions?.find(f => f.kind === 'channel' || f.kind === 'shared')
54
+ if (live) needs.sockets = { file: '.weft/contract.json', what: `\`${live.name}\` is a ${live.kind === 'shared' ? '`sharedState`' : '`channel`'}` }
55
+ streams = contract.functions?.some(f => ['stream', 'channel', 'shared'].includes(f.kind))
56
+ const native = Object.entries(contract.services ?? {}).find(([, s]) => s.kind === 'native')
57
+ if (native) needs.native = { file: '.weft/contract.json', what: `\`native('${native[0]}')\`` }
58
+ }
59
+ for (const f of files) {
60
+ const text = read(f)
61
+ const server = /^src\/server\/|\.server\.[cm]?[jt]sx?$/.test(rel(f))
62
+ if (!contract && server) {
63
+ const m = /\b(channel|sharedState)\s*[(<]/.exec(text)
64
+ if (m && !needs.sockets) needs.sockets = { file: rel(f), what: `a \`${m[1]}\`` }
65
+ if (/\b(stream|channel|sharedState)\s*[(<]/.test(text)) streams = true
66
+ }
67
+ if (/\.live\(/.test(text)) streams = true
68
+ if (/['"]@weft-js\/start\/rust['"]/.test(text)) {
69
+ for (const m of text.matchAll(/\bremote\(\s*['"`]rust:\/\/([\w-]+)/g)) if (!config.rust?.services?.[m[1]]?.external) needs.sidecar ??= { file: rel(f), what: `\`remote('rust://${m[1]}')\`` }
70
+ const n = /\bnative\(\s*['"`]([\w-]+)/.exec(text)
71
+ if (n) needs.native ??= { file: rel(f), what: `\`native('${n[1]}')\`` }
72
+ }
73
+ if (!/listener:\s*['"]external['"]/.test(realtime) && /\.live\(([^;\n]*?)\brequired:\s*true|\blive:\s*\{[^}]*required:\s*true/.test(text)) needs.required ??= { file: rel(f), what: 'a live query marked `required`' }
74
+ if (/\bctx\.media\b|\bz\.media\b/.test(text)) needs.uploads ??= { file: rel(f), what: 'media uploads' }
75
+ }
76
+ if (!needs.sockets && /\bshare\s*[:,}]/.test(realtime)) needs.sockets = { file: 'src/realtime.server.ts', what: '`realtime({ share })`' }
77
+ if (/\bchanges\s*[:,}]/.test(realtime) && !/listener:\s*['"]external['"]/.test(realtime)) needs.feed = { file: 'src/realtime.server.ts', what: '`changes`' }
78
+ const plugins = (config.plugins ?? []).map(p => p?.name ?? p).filter(n => typeof n === 'string')
79
+ const configText = ['weft.config.ts', 'weft.config.mts', 'weft.config.js', 'weft.config.mjs'].map(n => read(join(root, n))).join('\n')
80
+ if ((streams || needs.sockets) && !BUSES.test(realtime) && !plugins.some(n => /realtime-(redis|nats|durable-object)/.test(n)) && !BUSES.test(configText)) needs.memoryBus = { file: existsSync(realtimeFile) ? 'src/realtime.server.ts' : 'src', what: 'the memory bus' }
81
+ if (!needs.uploads && /encoder:\s*['"]server['"]/.test(configText)) needs.uploads = { file: 'weft.config.ts', what: "`media({ encoder: 'server' })`" }
82
+ if (needs.uploads && !plugins.some(n => n.includes('plugin-media')) && !configText.includes('plugin-media')) delete needs.uploads // M017 says that one
83
+ const db = read(join(root, 'src/db/index.server.ts'))
84
+ const drizzle = read(join(root, 'drizzle.config.ts'))
85
+ if (/\/db\/sqlite['"]/.test(db) || (/dialect:\s*['"](sqlite|turso)['"]/.test(drizzle) && !/libsql:|turso/.test(drizzle + db))) needs.sqlite = { file: db ? 'src/db/index.server.ts' : 'drizzle.config.ts', what: 'a SQLite file' }
86
+ try {
87
+ const pkg = JSON.parse(read(join(root, 'package.json')) || '{}')
88
+ const addon = Object.keys(pkg.dependencies ?? {}).find(n => ADDONS.test(n))
89
+ if (addon) needs.addons = { file: 'package.json', what: `\`${addon}\`` }
90
+ } catch {}
91
+ return needs
92
+ }
@@ -0,0 +1,439 @@
1
+ // The design D-codes in `weft check` (PLAN §6, research 37). Two families:
2
+ //
3
+ // D001–D007 off-system values, hand-rolled layout and role drift. Only where the app has warp's tokens to use instead (`@weft-js/warp`
4
+ // in package.json), so an app that chose raw Tailwind or its own CSS is left alone.
5
+ // D011, D030–D039 contrast and accessibility, in every app.
6
+ //
7
+ // An autofix is offered only when it keeps the meaning: a length that is exactly a scale step, a tag swap, a Tailwind
8
+ // class with an exact equivalent. A raw colour never gets one, because a token follows the theme and the raw value
9
+ // didn't; the agent chooses from `expected`. `check: { design: false }` turns both families off, and
10
+ // `// weft-allow D001: <reason>` on the line above keeps one site. Reads the JSX of the checked files and the app's .css.
11
+
12
+ import { existsSync, readFileSync } from 'node:fs'
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'
17
+
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
+ export function hasTokens(root) {
196
+ const file = join(root, 'package.json')
197
+ if (!existsSync(file)) return false
198
+ try {
199
+ const { dependencies, devDependencies } = JSON.parse(readFileSync(file, 'utf8'))
200
+ return '@weft-js/warp' in { ...dependencies, ...devDependencies }
201
+ } catch {
202
+ return false
203
+ }
204
+ }
205
+
206
+ /**
207
+ * @param {string} root
208
+ * @param {{ files: { rel: string, text: string }[], exclude?: string[], tokens: boolean }} options
209
+ */
210
+ 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
212
+ const out = []
213
+ 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] })
343
+
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
+ }
365
+
366
+ for (const [level, sites] of looks) roleDrift(level, sites)
367
+
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
+ }
386
+
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
+ }
393
+ return out
394
+ }
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 ADDED
@@ -0,0 +1,74 @@
1
+ // One diagnostic shape for every command (PLAN §3.5, §7: code, location, why, fix).
2
+ //
3
+ // { code, severity, owner, file, span: [line, col, endLine, endCol], offset: [start, end], why, fix: { text, safe?, edits? }, docs }
4
+ //
5
+ // Lines and columns are 1-based, columns in UTF-16 units (what editors count). `offset` is UTF-16 indices into the
6
+ // file's text, so `text.slice(...offset)` is the flagged code. Edits use the same two forms.
7
+
8
+ import { entry } from './catalog.mjs'
9
+
10
+ /** Position helpers over one file's text. The compiler reports UTF-8 byte offsets; JS strings index UTF-16. */
11
+ export function positions(text) {
12
+ const bytes = Buffer.from(text)
13
+ const ascii = bytes.length === text.length
14
+ const lineStarts = [0]
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 => {
18
+ let lo = 0
19
+ let hi = lineStarts.length - 1
20
+ while (lo < hi) {
21
+ const mid = (lo + hi + 1) >> 1
22
+ if (lineStarts[mid] <= index) lo = mid
23
+ else hi = mid - 1
24
+ }
25
+ return [lo + 1, index - lineStarts[lo] + 1]
26
+ }
27
+ const span = (start, end) => [...lineCol(start), ...lineCol(end)]
28
+ return { fromByte, lineCol, span }
29
+ }
30
+
31
+ export function diagnostic({ code, severity, file, span = [1, 1, 1, 1], offset, why, help, fix, related, owner = 'core' }) {
32
+ const known = entry(code)
33
+ const d = { code, severity: severity ?? known?.severity ?? 'error', owner, file, span }
34
+ if (offset) d.offset = offset
35
+ d.why = why
36
+ d.fix = { text: help ?? known?.fix ?? '', ...fix }
37
+ if (related?.length) d.related = related
38
+ d.docs = `weft docs ${code}`
39
+ return d
40
+ }
41
+
42
+ /** Span of `needle` in `text` (a task name, a plugin name), preferring it as a key, then as a quoted string; else the file start. */
43
+ export function locate(text, needle) {
44
+ if (!needle || !text) return { span: [1, 1, 1, 1] }
45
+ const esc = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
46
+ for (const re of [new RegExp(`(?<![\\w$.-])${esc}(?=\\s*:)`), new RegExp(`(['"\`])${esc}\\1`), new RegExp(esc)]) {
47
+ const m = re.exec(text)
48
+ if (!m) continue
49
+ const at = m.index + (m[0].length - needle.length) / 2
50
+ return { span: positions(text).span(at, at + needle.length), offset: [at, at + needle.length] }
51
+ }
52
+ return { span: [1, 1, 1, 1] }
53
+ }
54
+
55
+ export function summarize(diagnostics) {
56
+ const byCode = {}
57
+ for (const d of diagnostics) byCode[d.code] = (byCode[d.code] ?? 0) + 1
58
+ return {
59
+ errors: diagnostics.filter(d => d.severity === 'error').length,
60
+ warnings: diagnostics.filter(d => d.severity === 'warning').length,
61
+ byCode,
62
+ }
63
+ }
64
+
65
+ 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))
67
+ }
68
+
69
+ /** Human text for stderr: `file:line:col CODE severity why` then the fix. */
70
+ export function formatHuman(diagnostics) {
71
+ 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)' : ''}`)
73
+ .join('\n')
74
+ }