@weft-js/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) 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/design.md +97 -0
  8. package/docs/fonts.md +70 -0
  9. package/docs/icons.md +102 -0
  10. package/docs/lists.md +23 -0
  11. package/docs/meta.md +82 -0
  12. package/docs/plugins.md +143 -0
  13. package/docs/props.md +77 -0
  14. package/docs/rules.md +32 -0
  15. package/docs/rust.md +223 -0
  16. package/docs/start-short.md +8 -0
  17. package/docs/start.md +422 -0
  18. package/docs/state.md +96 -0
  19. package/docs/style.md +59 -0
  20. package/docs/surface.md +111 -0
  21. package/docs/testing.md +71 -0
  22. package/docs/ui-state.md +92 -0
  23. package/docs/verify.md +48 -0
  24. package/package.json +39 -4
  25. package/src/add-rust.mjs +123 -0
  26. package/src/add.mjs +358 -0
  27. package/src/agents-md.mjs +134 -0
  28. package/src/alias.mjs +59 -0
  29. package/src/audit.mjs +333 -0
  30. package/src/browser/cli.mjs +34 -0
  31. package/src/browser/client.mjs +132 -0
  32. package/src/browser/commands.mjs +143 -0
  33. package/src/catalog.mjs +479 -0
  34. package/src/check.mjs +328 -0
  35. package/src/cli.mjs +332 -0
  36. package/src/config.d.ts +1 -0
  37. package/src/config.mjs +5 -0
  38. package/src/contributions.mjs +69 -0
  39. package/src/db-layer.mjs +33 -0
  40. package/src/design-checks.mjs +439 -0
  41. package/src/diag.mjs +74 -0
  42. package/src/docs.mjs +131 -0
  43. package/src/doctor.mjs +30 -0
  44. package/src/files.mjs +47 -0
  45. package/src/fix.mjs +40 -0
  46. package/src/gate.mjs +276 -0
  47. package/src/index.mjs +9 -0
  48. package/src/infer/engine.cjs +525 -0
  49. package/src/infer/facts.cjs +252 -0
  50. package/src/infer/hosts.cjs +114 -0
  51. package/src/infer/lsp.mjs +131 -0
  52. package/src/infer/session.cjs +105 -0
  53. package/src/infer/shadow.cjs +46 -0
  54. package/src/infer/worker.cjs +97 -0
  55. package/src/jsx-scan.mjs +86 -0
  56. package/src/load-config.mjs +274 -0
  57. package/src/map.mjs +110 -0
  58. package/src/mcp.mjs +195 -0
  59. package/src/meta-table.mjs +28 -0
  60. package/src/mutate.mjs +140 -0
  61. package/src/plugin-checks.mjs +89 -0
  62. package/src/plugins.mjs +172 -0
  63. package/src/project.mjs +219 -0
  64. package/src/props-checks.mjs +46 -0
  65. package/src/props-listener.mjs +38 -0
  66. package/src/react-checks.mjs +36 -0
  67. package/src/share-checks.mjs +74 -0
  68. package/src/site.mjs +19 -0
  69. package/src/slots.mjs +96 -0
  70. package/src/start.mjs +292 -0
  71. package/src/state-checks.mjs +213 -0
  72. package/src/style.mjs +320 -0
  73. package/src/surface-checks.mjs +218 -0
  74. package/src/tailwind.mjs +22 -0
  75. package/src/tasks.mjs +99 -0
  76. package/src/testdiff.mjs +157 -0
  77. package/src/tests.mjs +572 -0
  78. package/src/text-checks.mjs +172 -0
  79. package/src/tokens.mjs +24 -0
  80. package/src/trust.mjs +282 -0
  81. package/src/weft.mjs +66 -0
  82. package/src/workspace.mjs +105 -0
  83. package/ts-plugin/index.js +137 -0
  84. package/ts-plugin/package.json +1 -0
package/src/style.mjs ADDED
@@ -0,0 +1,320 @@
1
+ // `weft style find|explain|matrix` and the style D-codes (research 23 §2.4, §3). The matrix comes from the installed
2
+ // weft (`@weft-js/core/style/matrix`): the project is scanned with its compiler, so the CLI, the MCP tools and the Vite
3
+ // plugin's `.weft/styles.json` agree. D020 (class soup) is found here, on the source text: groups never write a class.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { pathToFileURL } from 'node:url'
8
+ import { diagnostic, locate, positions } from './diag.mjs'
9
+ import { discover } from './files.mjs'
10
+ import { loadConfig } from './load-config.mjs'
11
+ import { isTestFile } from './tests.mjs'
12
+ import { loadCompiler, resolveWeft } from './weft.mjs'
13
+
14
+ const usageError = message => Object.assign(new Error(message), { code: 'E_USAGE' })
15
+
16
+ /** The installed weft's matrix module, or null when this weft has no style groups. */
17
+ export async function styleModule(weft) {
18
+ const target = weft.pkg.exports?.['./style/matrix']
19
+ if (typeof target !== 'string') return null
20
+ return import(pathToFileURL(join(weft.dir, target)).href)
21
+ }
22
+
23
+ /** Source files that can define or use groups: components and plain modules, not tests. */
24
+ export function styleFiles(root, config = {}) {
25
+ const exclude = [...(config.check?.exclude ?? []), ...(config.scope?.forbid ?? [])]
26
+ return discover(root, { include: ['**/*.{ts,tsx,jsx,mts}'], exclude }).filter(f => !isTestFile(f) && !f.endsWith('.d.ts'))
27
+ }
28
+
29
+ /**
30
+ * Scan the project and build the matrix. `write`: also write `.weft/styles.json` (when the project has groups, or the
31
+ * file is already there).
32
+ */
33
+ export async function styleMatrix(root, { write = false } = {}) {
34
+ const weft = resolveWeft(root)
35
+ const mod = await styleModule(weft)
36
+ if (!mod) throw Object.assign(new Error(`weft@${weft.version} has no style groups (@weft-js/core/style/matrix)`), { code: 'E_NO_STYLE' })
37
+ const { config } = await loadConfig(root)
38
+ const compiler = loadCompiler(weft)
39
+ const scan = mod.scanProject(root, styleFiles(root, config), (...a) => compiler.transform(...a))
40
+ const matrix = mod.buildMatrix(scan.groups, scan.uses)
41
+ const file = join(root, '.weft', 'styles.json')
42
+ if (write && (scan.groups.length || existsSync(file))) writeMatrixFile(root, matrix)
43
+ return { matrix, scan, mod, file }
44
+ }
45
+
46
+ export function writeMatrixFile(root, matrix) {
47
+ const file = join(root, '.weft', 'styles.json')
48
+ const text = `${JSON.stringify(matrix, null, 2)}\n`
49
+ if (existsSync(file) && readFileSync(file, 'utf8') === text) return false
50
+ mkdirSync(join(root, '.weft'), { recursive: true })
51
+ writeFileSync(file, text)
52
+ return true
53
+ }
54
+
55
+ const words = s => s.toLowerCase().split(/[\s\-_.:/=,]+|(?<=[a-z])(?=[A-Z])/).filter(Boolean)
56
+
57
+ /** Edit distance with adjacent swaps as one edit (`ordre` → `order`). */
58
+ function distance(a, b) {
59
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (__, j) => (i ? (j ? 0 : i) : j)))
60
+ for (let i = 1; i <= a.length; i++)
61
+ for (let j = 1; j <= b.length; j++) {
62
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
63
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1)
64
+ }
65
+ return d[a.length][b.length]
66
+ }
67
+
68
+ /** The names closest to `word` (edit distance within a third of its length, or containing it). */
69
+ export function closest(word, names, n = 3) {
70
+ const w = word.toLowerCase()
71
+ const limit = Math.max(1, Math.floor(w.length / 3))
72
+ return names
73
+ .map(x => [distance(w, x.toLowerCase()), x])
74
+ .filter(([d, x]) => d <= limit || x.toLowerCase().includes(w) || w.includes(x.toLowerCase()))
75
+ .sort((a, b) => a[0] - b[0])
76
+ .slice(0, n)
77
+ .map(([, x]) => x)
78
+ }
79
+
80
+ /**
81
+ * `weft style find <query>`: the groups that fit, best first. Every word of the query must hit the group's name,
82
+ * doc, parts, axes, values, states or tokens (a name word one typo off counts); with no such group, groups that hit
83
+ * any word.
84
+ */
85
+ export function findGroups(matrix, query) {
86
+ const terms = words(query ?? '')
87
+ if (!terms.length) throw usageError('weft style find <query>: say what the thing is (`card`, `status badge`, `critical`)')
88
+ const score = (name, g) => {
89
+ const fields = [
90
+ [words(name), 30],
91
+ [words(g.doc ?? ''), 10],
92
+ [[...g.parts, ...g.states, ...Object.keys(g.axes)].flatMap(words), 8],
93
+ [Object.values(g.axes).flatMap(v => (Array.isArray(v) ? v : [])).flatMap(words), 6],
94
+ [g.tokens.flatMap(words), 4],
95
+ ]
96
+ let total = name === query ? 100 : 0
97
+ const matched = []
98
+ let hits = 0
99
+ for (const t of terms) {
100
+ let best = 0
101
+ let why = null
102
+ for (const [ws, weight] of fields) {
103
+ for (const w of ws) {
104
+ const s = w === t ? weight : w.startsWith(t) || (t.length > 3 && w.includes(t)) ? weight * 0.7 : weight >= 30 && t.length > 3 && distance(w, t) === 1 ? weight * 0.5 : 0
105
+ if (s > best) [best, why] = [s, w]
106
+ }
107
+ }
108
+ if (best) {
109
+ hits++
110
+ matched.push(why)
111
+ }
112
+ total += best
113
+ }
114
+ return { total, hits, matched: [...new Set(matched)] }
115
+ }
116
+ const all = Object.entries(matrix.groups).map(([name, g]) => ({ name, g, ...score(name, g) })).filter(x => x.total > 0)
117
+ const full = all.filter(x => x.hits === terms.length)
118
+ const ranked = (full.length ? full : all).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name))
119
+ // A name or doc hit outranks groups that only share a token (`row` → product-row, not every `layout: row`).
120
+ const picked = ranked.filter(x => x.total >= (ranked[0]?.total ?? 0) / 4)
121
+ return {
122
+ query,
123
+ groups: picked.map(({ name, g, total, matched }) => ({
124
+ name,
125
+ score: Math.round(total),
126
+ matched,
127
+ doc: g.doc,
128
+ file: g.file,
129
+ import: g.binding && g.exported ? { name: g.binding, from: g.file.replace(/:\d+$/, '') } : null,
130
+ axes: g.axes,
131
+ defaults: g.defaults,
132
+ parts: g.parts,
133
+ states: g.states,
134
+ uses: g.used.length,
135
+ similar: matrix.similar.filter(s => s[0] === name || s[1] === name).map(s => (s[0] === name ? s[1] : s[0])),
136
+ })),
137
+ partial: !full.length && all.length > 0,
138
+ total: Object.keys(matrix.groups).length,
139
+ }
140
+ }
141
+
142
+ const ARIA = new Set(['selected', 'pressed', 'checked', 'expanded', 'current', 'invalid', 'busy'])
143
+
144
+ /**
145
+ * Read `order-card`, `order-card.title`, `order-card[status=late][selected]`, or a DOM selector
146
+ * (`[data-g="order-card"][data-status="late"] [data-part="title"]`, `[aria-selected="true"]`, `[data-accent~="radius:round"]`).
147
+ */
148
+ export function parseTarget(target) {
149
+ const t = (target ?? '').trim()
150
+ if (!t) throw usageError('weft style explain <group|selector>: a group name (`order-card`), `order-card.title`, or a selector')
151
+ const out = { group: null, part: null, axes: {}, states: [], accent: {} }
152
+ const head = /^([a-z][\w-]*)(?:\.([\w-]+))?/i.exec(t)
153
+ if (head && !t.startsWith('[')) {
154
+ out.group = head[1]
155
+ out.part = head[2] ?? null
156
+ }
157
+ for (const m of t.matchAll(/\[\s*([\w-]+)\s*(?:[~|^$*]?=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]*)))?\s*\]/g)) {
158
+ const [, attr, ...vals] = m
159
+ const value = vals.find(v => v !== undefined)
160
+ if (attr === 'data-g') out.group = value
161
+ else if (attr === 'data-part') out.part = value
162
+ else if (attr === 'data-accent') for (const w of (value ?? '').split(/\s+/).filter(Boolean)) { const [k, v] = w.split(':'); out.accent[k] = v }
163
+ else if (attr.startsWith('aria-')) { if (value !== 'false') out.states.push(attr.slice(5)) }
164
+ else {
165
+ const name = attr.replace(/^data-/, '')
166
+ if (value === undefined || value === '') out.states.push(name)
167
+ else out.axes[name] = value
168
+ }
169
+ }
170
+ if (!out.group) throw usageError(`weft style explain: no group in \`${t}\``)
171
+ return out
172
+ }
173
+
174
+ /**
175
+ * `weft style explain <target>`: the group, and for the selection (defaults filled in) every declaration on the root
176
+ * and its parts in cascade order, with where each came from, plus the CSS that selection emits.
177
+ * @param {{ axes?: Record<string, string>, states?: string[], accent?: Record<string, string>, part?: string }} [extra]
178
+ */
179
+ export function explainTarget({ matrix, scan, mod }, target, extra = {}) {
180
+ const t = parseTarget(target)
181
+ const g = matrix.groups[t.group]
182
+ if (!g) {
183
+ const near = closest(t.group, Object.keys(matrix.groups))
184
+ throw Object.assign(new Error(`D025 no group \`${t.group}\`${near.length ? `; did you mean ${near.map(n => `\`${n}\``).join(' or ')}?` : ''} (\`weft style find\` searches by what it is)`), { code: 'E_UNKNOWN_GROUP' })
185
+ }
186
+ const source = scan.groups.find(s => s.name === t.group)
187
+ // A state written as data-x="…" in a selector, or an axis passed as --x: sort them by what the group has.
188
+ const axes = { ...t.axes, ...extra.axes }
189
+ const states = [...t.states, ...(extra.states ?? [])]
190
+ for (const [k, v] of Object.entries(axes)) if (!(k in g.axes) && g.states.includes(k) && (v === 'true' || v === '')) { delete axes[k]; states.push(k) }
191
+ const selection = { axes, states: [...new Set(states)],accent: { ...t.accent, ...extra.accent }, part: extra.part ?? t.part ?? undefined }
192
+ let explained
193
+ try {
194
+ explained = mod.explainGroup(source, selection)
195
+ } catch (e) {
196
+ if (e.constructor?.name === 'GroupError') throw Object.assign(new Error(`D025 ${e.message}`), { code: 'E_UNKNOWN_NAME' })
197
+ throw e
198
+ }
199
+ const sel = explained.selection
200
+ const sites = g.used.filter(u => !u.part && Object.entries(sel.axes).every(([a, v]) => !u.axes[a] || u.axes[a] === 'dynamic' || u.axes[a].includes(v)))
201
+ return {
202
+ group: t.group,
203
+ doc: g.doc,
204
+ file: g.file,
205
+ axes: g.axes,
206
+ defaults: g.defaults,
207
+ parts: g.parts,
208
+ states: g.states,
209
+ contextTone: g.contextTone,
210
+ selection: sel,
211
+ attributes: attributesOf(t.group, sel, selection.part),
212
+ elements: explained.elements,
213
+ css: explained.css,
214
+ sites,
215
+ accents: g.accents,
216
+ similar: matrix.similar.filter(s => s[0] === t.group || s[1] === t.group),
217
+ }
218
+ }
219
+
220
+ /** What the selection looks like in the DOM, as a person would find it in devtools. */
221
+ function attributesOf(group, sel, part) {
222
+ const root = [`data-g="${group}"`, ...Object.entries(sel.axes).map(([a, v]) => `data-${a}="${v}"`), ...sel.states.map(s => (ARIA.has(s) ? `aria-${s}="true"` : `data-${s}`))]
223
+ const accent = Object.entries(sel.accent).sort(([a], [b]) => (a < b ? -1 : 1)).map(([k, v]) => `${k}:${v}`).join(' ')
224
+ if (accent) root.push(`data-accent="${accent}"`)
225
+ return { root: root.join(' '), ...(part ? { part: `data-part="${part}"` } : {}) }
226
+ }
227
+
228
+ export function formatFind(r) {
229
+ if (!r.groups.length) return `no group fits "${r.query}" (${r.total} groups). Define one with group() from @weft-js/core/style; \`weft docs style\` has the rules.`
230
+ const lines = r.groups.map(g => {
231
+ const axes = Object.entries(g.axes).map(([a, v]) => `${a}: ${Array.isArray(v) ? v.join('|') : v}`).join(' · ')
232
+ return `${g.name.padEnd(18)} ${g.file}${g.doc ? `\n ${g.doc}` : ''}\n ${[axes, g.parts.length ? `parts: ${g.parts.join(', ')}` : '', g.states.length ? `states: ${g.states.join(', ')}` : '', `${g.uses} uses`].filter(Boolean).join(' · ')}`
233
+ })
234
+ return (r.partial ? `no group matches every word of "${r.query}"; closest:\n` : '') + lines.join('\n')
235
+ }
236
+
237
+ export function formatExplain(r) {
238
+ const out = [`${r.group} ${r.file}${r.doc ? ` — ${r.doc}` : ''}`, ` <el ${r.attributes.root}>${r.attributes.part ? ` part: <el ${r.attributes.part}>` : ''}`]
239
+ for (const el of r.elements) {
240
+ if (!el.decls.length) continue
241
+ out.push(` ${el.element}:`)
242
+ for (const d of el.decls) out.push(` ${d.overridden ? '~' : ' '} ${`${d.prop}: ${d.value}`.padEnd(64)} ← ${d.from} (${d.at})${d.overridden ? ', overridden' : ''}`)
243
+ }
244
+ out.push('', r.css.trimEnd())
245
+ if (r.contextTone) out.push('', 'Reads its tone from the nearest data-tone (warp context tone).')
246
+ return out.join('\n')
247
+ }
248
+
249
+ /** Long class lists are how Tailwind is written, so D020 stays out of an app that chose it. */
250
+ export function usesTailwind(root) {
251
+ const file = join(root, 'package.json')
252
+ if (!existsSync(file)) return false
253
+ const { dependencies, devDependencies } = JSON.parse(readFileSync(file, 'utf8'))
254
+ return 'tailwindcss' in { ...dependencies, ...devDependencies }
255
+ }
256
+
257
+ /**
258
+ * D020: class soup. A static `class`/`className` with more than 8 classes, or one class list (4+ classes, any order)
259
+ * in two or more places. The fix names a group after the component and element.
260
+ * @param {{ rel: string, text: string }[]} files
261
+ */
262
+ export function classSoup(files) {
263
+ const out = []
264
+ const lists = new Map()
265
+ const attr = /\b(class|className)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*(["'`])([^"'`$]*)\4\s*\})/g
266
+ for (const { rel, text } of files) {
267
+ const pos = positions(text)
268
+ const named = new Map()
269
+ for (const m of text.matchAll(attr)) {
270
+ const value = m[2] ?? m[3] ?? m[5] ?? ''
271
+ const classes = value.split(/\s+/).filter(Boolean)
272
+ if (!classes.length) continue
273
+ const before = text.slice(0, m.index)
274
+ const component = [...before.matchAll(/(?:function\s+([A-Z]\w*)|(?:const|let)\s+([A-Z]\w*)\s*=)/g)].at(-1)
275
+ const tag = [...before.matchAll(/<([a-z][\w-]*)\b/g)].at(-1)?.[1] ?? 'div'
276
+ const base = component ? (component[1] ?? component[2]).replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase() : rel.replace(/^.*\//, '').replace(/\.\w+$/, '').toLowerCase()
277
+ const n = (named.get(base) ?? 0) + 1
278
+ named.set(base, n)
279
+ const name = n === 1 ? base : `${base}-${tag}`
280
+ const at = { span: pos.span(m.index, m.index + m[0].length), offset: [m.index, m.index + m[0].length] }
281
+ const site = { rel, at, name, count: classes.length }
282
+ if (classes.length > 8) out.push(diagnostic({ code: 'D020', severity: 'warning', file: rel, ...at, why: `<${tag}> carries ${classes.length} classes: nobody can read what it is or which variation it's in.`, help: groupHelp(name) }))
283
+ if (classes.length >= 4) {
284
+ const key = [...classes].sort().join(' ')
285
+ lists.set(key, [...(lists.get(key) ?? []), site])
286
+ }
287
+ }
288
+ }
289
+ for (const sites of lists.values()) {
290
+ if (sites.length < 2) continue
291
+ for (const s of sites.slice(1)) {
292
+ const first = sites[0]
293
+ out.push(diagnostic({
294
+ code: 'D020', severity: 'warning', file: s.rel, ...s.at,
295
+ why: `The same ${s.count} classes are written in ${sites.length} places (first at ${first.rel}:${first.at.span[0]}): one look, copied by hand.`,
296
+ help: groupHelp(first.name),
297
+ related: sites.filter(x => x !== s).map(x => ({ file: x.rel, span: x.at.span })),
298
+ }))
299
+ }
300
+ }
301
+ return out
302
+ }
303
+
304
+ const groupHelp = name => `Extract a group: \`export const ${name.replace(/-(\w)/g, (_, c) => c.toUpperCase())} = group('${name}', { base: { … } })\` and spread it (\`{...${name.replace(/-(\w)/g, (_, c) => c.toUpperCase())}()}\`). \`weft style find\` first: one may already fit.`
305
+
306
+ /** Matrix diagnostics (D021–D025) as `weft check` diagnostics, located on the group or the site. */
307
+ export function matrixDiagnostics(root, matrix) {
308
+ const texts = new Map()
309
+ const text = file => {
310
+ if (!texts.has(file)) texts.set(file, existsSync(join(root, file)) ? readFileSync(join(root, file), 'utf8') : '')
311
+ return texts.get(file)
312
+ }
313
+ return matrix.diagnostics.map(d => {
314
+ const lines = text(d.file).split('\n')
315
+ const line = Math.max(1, Math.min(d.line, lines.length))
316
+ const col = (lines[line - 1]?.search(/\S/) ?? 0) + 1
317
+ const loc = d.line ? { span: [line, col, line, (lines[line - 1]?.length ?? 0) + 1] } : locate(text(d.file), d.group)
318
+ return diagnostic({ code: d.code, severity: d.severity, file: d.file, ...loc, why: d.message, help: d.help })
319
+ })
320
+ }
@@ -0,0 +1,218 @@
1
+ // Surfaces in `weft check` (research 33 §8): X002 and X009–X012 for surfaces and their text, X040 and X041 for per-glyph
2
+ // markup, and P101 for a surface that needs a slot nothing provides. X001, X003 and X004 need the live mirror, so
3
+ // @weft-js/core/surface reports those in dev. `listSurfaces` is the catalog behind `weft surfaces` and the MCP tool.
4
+
5
+ import { readFileSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import ts from 'typescript'
8
+ import { diagnostic, positions } from './diag.mjs'
9
+ import { discover } from './files.mjs'
10
+ import { scanJsx } from './jsx-scan.mjs'
11
+
12
+ const CODE = /\.[cm]?[jt]sx?$/
13
+ const SYSTEM = /(^|,)\s*["']?(system-ui|-apple-system|BlinkMacSystemFont)["']?\s*(,|$)/i
14
+ const GENERIC_ONLY = /^\s*(serif|sans-serif|monospace|cursive|fantasy)\s*$/i
15
+ const GLYPHS = 200
16
+
17
+ const parse = (rel, text) => ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
18
+
19
+ const prop = (object, name) => object.properties.find(p => (ts.isPropertyAssignment(p) || ts.isMethodDeclaration(p) || ts.isShorthandPropertyAssignment(p)) && p.name.getText() === name)
20
+ const stringOf = node => (node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) ? node.text : undefined)
21
+
22
+ function walk(node, visit) {
23
+ visit(node)
24
+ ts.forEachChild(node, c => walk(c, visit))
25
+ }
26
+
27
+ /** Every `defineSurface({ ... })` call in a file: the call, its options and the name of `setup`'s `s`. */
28
+ function surfaceCalls(sf) {
29
+ const out = []
30
+ walk(sf, node => {
31
+ if (!ts.isCallExpression(node) || node.expression.getText() !== 'defineSurface') return
32
+ const def = node.arguments[0]
33
+ if (!def || !ts.isObjectLiteralExpression(def)) return
34
+ const setup = prop(def, 'setup')
35
+ const fn = setup && (ts.isMethodDeclaration(setup) ? setup : setup.initializer)
36
+ out.push({ node, def, fn, param: fn?.parameters?.[0]?.name.getText() })
37
+ })
38
+ return out
39
+ }
40
+
41
+ function propNames(call) {
42
+ const schema = prop(call.def, 'props')?.initializer
43
+ if (schema && ts.isCallExpression(schema) && schema.expression.getText().endsWith('.object') && ts.isObjectLiteralExpression(schema.arguments[0] ?? schema)) return schema.arguments[0].properties.map(p => p.name.getText())
44
+ const type = call.node.typeArguments?.[0]
45
+ if (type && ts.isTypeLiteralNode(type)) return type.members.map(m => m.name?.getText()).filter(Boolean)
46
+ return []
47
+ }
48
+
49
+ /** What the project's `defineSurface` calls declare, read statically: nothing needs to build. */
50
+ export function listSurfaces(root, { exclude = [] } = {}) {
51
+ const out = []
52
+ for (const rel of discover(root, { include: ['src/**/*.{ts,tsx,js,jsx}'], exclude })) {
53
+ const text = readFileSync(join(root, rel), 'utf8')
54
+ if (!text.includes('defineSurface')) continue
55
+ const sf = parse(rel, text)
56
+ for (const call of surfaceCalls(sf)) {
57
+ const line = sf.getLineAndCharacterOfPosition(call.node.getStart(sf)).line + 1
58
+ out.push({ name: stringOf(prop(call.def, 'name')?.initializer) ?? null, kind: stringOf(prop(call.def, 'kind')?.initializer) ?? null, props: propNames(call), file: rel, line, ...(prop(call.def, 'pixel') ? { pixel: true } : {}) })
59
+ }
60
+ }
61
+ return out.sort((a, b) => String(a.name).localeCompare(String(b.name)))
62
+ }
63
+
64
+ /** `[...x]`, `x.split('')` or `Array.from(x)`: the string being spread, or undefined. */
65
+ function glyphSource(node) {
66
+ if (ts.isArrayLiteralExpression(node) && node.elements.length === 1 && ts.isSpreadElement(node.elements[0])) return node.elements[0].expression
67
+ if (!ts.isCallExpression(node)) return undefined
68
+ const callee = node.expression.getText()
69
+ if (callee === 'Array.from' && node.arguments.length === 1 && !ts.isObjectLiteralExpression(node.arguments[0])) return node.arguments[0]
70
+ if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'split' && stringOf(node.arguments[0]) === '') return node.expression.expression
71
+ return undefined
72
+ }
73
+
74
+ /** The first `<span>` a `.map` callback returns. */
75
+ function returnedSpan(fn) {
76
+ let span
77
+ walk(fn.body, n => {
78
+ if (span) return
79
+ const open = ts.isJsxElement(n) ? n.openingElement : ts.isJsxSelfClosingElement(n) ? n : undefined
80
+ if (open?.tagName.getText() === 'span') span = open
81
+ })
82
+ return span
83
+ }
84
+
85
+ const attrOf = (open, name) => open.attributes.properties.some(p => !ts.isJsxSpreadAttribute(p) && p.name.getText() === name)
86
+ const spreads = open => open.attributes.properties.some(ts.isJsxSpreadAttribute)
87
+
88
+ /** The JSX element a child expression sits in. */
89
+ function jsxParent(node) {
90
+ for (let n = node.parent; n; n = n.parent) if (ts.isJsxElement(n)) return n.openingElement
91
+ return undefined
92
+ }
93
+
94
+ /** A string literal, or a `const` in this file holding one. */
95
+ function knownString(sf, node) {
96
+ const direct = stringOf(node)
97
+ if (direct !== undefined) return direct
98
+ if (!ts.isIdentifier(node)) return undefined
99
+ let found
100
+ walk(sf, n => {
101
+ if (ts.isVariableDeclaration(n) && n.name.getText() === node.text && ts.isVariableDeclarationList(n.parent) && n.parent.flags & ts.NodeFlags.Const) found ??= stringOf(n.initializer)
102
+ })
103
+ return found
104
+ }
105
+
106
+ function glyphChecks(sf, report) {
107
+ walk(sf, node => {
108
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression) || node.expression.name.text !== 'map') return
109
+ const source = glyphSource(node.expression.expression)
110
+ const fn = node.arguments[0]
111
+ if (!source || !fn || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn))) return
112
+ const span = returnedSpan(fn)
113
+ if (!span) return
114
+ const text = knownString(sf, source)
115
+ const count = text === undefined ? 0 : Array.from(text).length
116
+ if (count > GLYPHS) report('X040', node, `Per-grapheme spans over ${count} graphemes: each is a DOM node, a style and a composited layer once it animates. Use word-level motion or a Surface.`)
117
+ const hidden = attrOf(span, 'aria-hidden') || spreads(span)
118
+ const parent = jsxParent(node)
119
+ const named = !parent || attrOf(parent, 'aria-label') || attrOf(parent, 'aria-labelledby') || spreads(parent)
120
+ if (!hidden || !named) report('X041', span, `Per-character spans read as separate letters to a screen reader${hidden ? '' : ': add `aria-hidden` to each span'}${named ? '' : `${hidden ? ':' : ', and'} give the parent an \`aria-label\` with the whole text`}.`)
121
+ })
122
+ }
123
+
124
+ function surfaceFileChecks(sf, slots, report) {
125
+ const calls = surfaceCalls(sf)
126
+ if (!calls.length) return
127
+ const decorative = new Set()
128
+ walk(sf, n => {
129
+ if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && n.expression.name.text === 'decorative') decorative.add(n)
130
+ })
131
+ const inside = n => { for (let p = n.parent; p; p = p.parent) if (decorative.has(p)) return true; return false }
132
+ walk(sf, n => {
133
+ if (!ts.isCallExpression(n) || !ts.isPropertyAccessExpression(n.expression)) return
134
+ const method = n.expression.name.text
135
+ if ((method === 'fillText' || method === 'strokeText') && !inside(n)) report('X010', n, `\`${method}\` in a surface draws text only the canvas knows about: no selection, find, translation or screen reader. Use \`s.text()\`, or \`s.decorative()\` for text that is truly decoration.`)
136
+ if (method === 'setAttribute' && stringOf(n.arguments[0]) === 'aria-hidden' && n.expression.expression.getText().endsWith('.el')) report('X011', n, 'This hides the surface or its text mirror from assistive tech. Leave the mirror to weft.')
137
+ })
138
+ for (const call of calls) {
139
+ const kindNode = prop(call.def, 'kind')?.initializer
140
+ const kind = stringOf(kindNode)
141
+ if (kind && slots && slots['surface.renderer'] && !slots['surface.renderer'][kind]) report('P101', kindNode, `Surface kind \`${kind}\` has no provider: add the plugin that provides it to \`plugins\` in weft.config.ts.`)
142
+ if (!call.param || !call.fn) continue
143
+ walk(call.fn, n => {
144
+ if (!ts.isCallExpression(n) || n.expression.getText() !== `${call.param}.text`) return
145
+ const options = n.arguments[1]
146
+ const font = options && ts.isObjectLiteralExpression(options) ? stringOf(prop(options, 'font')?.initializer) : undefined
147
+ const family = font?.replace(/^.*?\d[\d.]*px(\/\S+)?\s*/, '')
148
+ if (family !== undefined && (SYSTEM.test(family) || GENERIC_ONLY.test(family))) report('X002', n.arguments[1], `\`${font}\` can't be measured safely: \`system-ui\`, \`-apple-system\` and generic-only families resolve differently in canvas and CSS. Name a family.`)
149
+ if (kind === 'canvas2d' && slots && slots['text.measure'] === null) report('P101', n, 'Canvas `s.text()` needs `text.measure`, and nothing provides it: add `text()` from `@weft-js/text` to `plugins` in weft.config.ts.')
150
+ })
151
+ }
152
+ }
153
+
154
+ const HIDES = /(?:display\s*:\s*none|visibility\s*:\s*hidden)/
155
+
156
+ /** X011/X012 in a stylesheet: rules aimed at the host or the mirror. */
157
+ function cssChecks(file, report) {
158
+ const css = file.text.replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' '))
159
+ const rule = /([^{}]*\[data-surface(-text)?\b[^{}]*)\{([^}]*)\}/g
160
+ for (const m of css.matchAll(rule)) {
161
+ const at = m.index + m[0].indexOf('[data-surface')
162
+ const before = css.slice(0, m.index)
163
+ const open = before.lastIndexOf('@media')
164
+ const inPrint = open >= 0 && /^@media[^{]*\bprint\b/.test(before.slice(open)) && (before.slice(open).match(/\{/g) ?? []).length > (before.slice(open).match(/\}/g) ?? []).length
165
+ if (/forced-color-adjust\s*:\s*none/.test(m[3])) report('X012', at, at + 13, '`forced-color-adjust: none` on a surface keeps its author colours in forced-colours mode, where the mirror must take over.')
166
+ else if (inPrint && HIDES.test(m[3])) report('X012', at, at + 13, 'This hides surface text when printing, where the mirror is the only copy that prints sharply.')
167
+ else if (m[2] && HIDES.test(m[3])) report('X011', at, at + 18, 'This hides the text mirror from assistive tech, and it is the only copy a screen reader can read. Leave the mirror to weft.')
168
+ }
169
+ }
170
+
171
+ /**
172
+ * @param {string} root
173
+ * @param {{ files: { rel: string, text: string }[], exclude?: string[], slots?: Record<string, any> }} options
174
+ */
175
+ export function surfaceChecks(root, { files, exclude = [], slots }) {
176
+ const out = []
177
+ const reporter = file => {
178
+ const pos = positions(file.text)
179
+ return (code, start, end, why) => out.push(diagnostic({ code, file: file.rel, span: pos.span(start, end), offset: [start, end], why }))
180
+ }
181
+ const sources = new Map(files.filter(f => CODE.test(f.rel)).map(f => [f.rel, f]))
182
+ for (const rel of discover(root, { include: ['src/**/*.{ts,tsx,js,jsx}'], exclude })) {
183
+ if (!sources.has(rel)) sources.set(rel, { rel, text: readFileSync(join(root, rel), 'utf8') })
184
+ }
185
+ for (const f of sources.values()) {
186
+ const glyphs = /\.[jt]sx$/.test(f.rel) && /\.map\(/.test(f.text) && /<span\b/.test(f.text)
187
+ const surfaces = f.text.includes('@weft-js/core/surface')
188
+ if (!glyphs && !surfaces) continue
189
+ let sf
190
+ try {
191
+ sf = parse(f.rel, f.text)
192
+ } catch {
193
+ continue
194
+ }
195
+ const raw = reporter(f)
196
+ const report = (code, node, why) => raw(code, node.getStart(sf), node.end, why)
197
+ if (glyphs) glyphChecks(sf, report)
198
+ if (!surfaces) continue
199
+ surfaceFileChecks(sf, slots, report)
200
+ if (!/\.[jt]sx$/.test(f.rel)) continue
201
+ const scan = scanJsx(f.text, f.rel)
202
+ const local = scan.imports.find(i => i.source === '@weft-js/core/surface' && i.imported === 'Surface')?.local
203
+ for (const el of scan.elements) {
204
+ if (el.name !== local) continue
205
+ const spread = el.attrs.some(a => a.kind === 'spread')
206
+ if (!el.attr('label') && !el.attr('decorative') && !spread) raw('X009', el.start, el.tagEnd, `<${el.name}> has no accessible name: a screen reader finds an unnamed image. Add \`label\`, or \`decorative\` if it shows nothing a reader needs.`)
207
+ const hidden = el.attr('aria-hidden')
208
+ if (hidden && hidden.value !== 'false') raw('X011', hidden.start, hidden.end, 'This hides the surface and its text mirror from assistive tech. For a surface with nothing to read use `decorative`, which leaves mirrors alone.')
209
+ const style = el.attr('style')
210
+ if (style && /forced-color-adjust\s*:\s*none|forcedColorAdjust\s*:\s*['"]none/.test(style.value ?? style.expr ?? '')) raw('X012', style.start, style.end, '`forced-color-adjust: none` on a surface keeps its author colours in forced-colours mode, where the mirror must take over.')
211
+ }
212
+ }
213
+ for (const rel of discover(root, { include: ['**/*.css'], exclude })) {
214
+ const text = readFileSync(join(root, rel), 'utf8')
215
+ if (text.includes('data-surface')) cssChecks({ rel, text }, reporter({ rel, text }))
216
+ }
217
+ return out
218
+ }
@@ -0,0 +1,22 @@
1
+ // Tailwind v4 for a weft app (research 34 §5). `create-weft --ui tailwind` and `weft add tailwind` write the same files,
2
+ // and a create-weft test holds them equal, because create-weft can't depend on the CLI.
3
+
4
+ export const packages = { tailwindcss: '4.3.3', '@tailwindcss/vite': '4.3.3' }
5
+
6
+ export const viteConfig = `import tailwindcss from '@tailwindcss/vite'
7
+
8
+ export default { plugins: [tailwindcss()] }
9
+ `
10
+
11
+ /**
12
+ * The top of the app's CSS. With warp, Tailwind's reset stays out: its `base` layer comes after `warp`, so it would
13
+ * beat every warp component. warp brings its own base. Layer order has to be declared before any import.
14
+ */
15
+ export const cssEntry = warp => (warp
16
+ ? `@layer warp, theme, base, components, utilities;
17
+ @import "tailwindcss/theme.css" layer(theme);
18
+ @import "tailwindcss/utilities.css" layer(utilities);
19
+ @import "@weft-js/warp/tailwind.css";
20
+ `
21
+ : `@import "tailwindcss";
22
+ `)
package/src/tasks.mjs ADDED
@@ -0,0 +1,99 @@
1
+ // `weft tasks`: the single list of what can be run (PLAN §7b). Built-ins first, then the project's `tasks`.
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { existsSync } from 'node:fs'
5
+ import { join, relative, resolve } from 'node:path'
6
+ import { z } from 'zod'
7
+ import { loadConfig } from './load-config.mjs'
8
+
9
+ const files = { type: 'array', items: { type: 'string' }, description: 'Files relative to the project root; default: the whole project' }
10
+
11
+ /** `available: false` marks commands PLAN names that this CLI does not ship yet; `stack` ones ship for apps with src/routes. */
12
+ export const builtins = [
13
+ { name: 'check', description: 'Compile every component in check mode and report W- and P-diagnostics with fixes', args: { type: 'object', properties: { files } }, sideEffects: [], available: true },
14
+ { name: 'tasks', description: 'List every command and project task with args, side effects and environments', args: { type: 'object', properties: {} }, sideEffects: [], available: true },
15
+ { name: 'docs', description: 'Print a docs topic or a diagnostic code (`weft docs W014`)', args: { type: 'object', properties: { topic: { type: 'string' } } }, sideEffects: [], available: true },
16
+ { name: 'agents-md', description: 'Regenerate AGENTS.md (`--check` fails if stale)', args: { type: 'object', properties: { check: { type: 'boolean' } } }, sideEffects: ['fs:AGENTS.md'], available: true },
17
+ { name: 'mcp', description: 'These commands as MCP tools (stdio)', args: { type: 'object', properties: {} }, sideEffects: [], available: true, longRunning: true },
18
+ { name: 'style', description: 'Style groups: `find <query>`, `explain <group|selector>`, `matrix` (writes .weft/styles.json)', args: { type: 'object', properties: { subcommand: { type: 'string', enum: ['find', 'explain', 'matrix'] }, query: { type: 'string' }, target: { type: 'string' } } }, sideEffects: ['fs:.weft/styles.json'], available: true },
19
+ { name: 'map', description: 'Write `.weft/map.md`: a matrix of what to call in this app', args: { type: 'object', properties: {} }, sideEffects: ['fs:.weft/map.*'], available: true },
20
+ { name: 'add', description: 'Install a plugin and wire it into the config (`--dry-run` shows the changes)', args: { type: 'object', properties: { plugin: { type: 'string' }, 'dry-run': { type: 'boolean' } } }, sideEffects: ['fs:weft.config.*', 'fs:package.json', 'network:install'], available: true },
21
+ { name: 'dev', description: 'Dev server', sideEffects: ['network:listen'], available: false, stack: true, longRunning: true },
22
+ { name: 'build', description: 'Production build and contract', sideEffects: ['fs:.output/**'], available: false, stack: true },
23
+ { name: 'start', description: 'Serve the build', sideEffects: ['network:listen'], available: false, stack: true, longRunning: true },
24
+ { name: 'db', description: '`generate`: new migrations', sideEffects: ['fs:drizzle/**', 'fs:migrations/**', 'fs:prisma/**'], available: false, stack: true },
25
+ { name: 'test', description: 'The test gate on the diff (`--gate`)', args: { type: 'object', properties: { gate: { type: 'boolean' }, base: { type: 'string' }, budget: { type: 'number' }, refactor: { type: 'boolean' } } }, sideEffects: ['fs:.weft/gate'], available: true },
26
+ { name: 'fix', description: 'Apply the safe autofixes from `weft check`', args: { type: 'object', properties: { files } }, sideEffects: ['fs:scope.write'], available: true },
27
+ { name: 'tokens', description: "warp's resolved tokens as DTCG 2025.10 in `.weft/tokens/` (Figma, Style Dictionary, Terrazzo)", args: { type: 'object', properties: {} }, sideEffects: ['fs:.weft/tokens/**'], available: true },
28
+ { name: 'browser', description: 'Drive the running app in any open browser by role/label/text (`weft browser help`)', args: { type: 'object', properties: { command: { type: 'string' } } }, sideEffects: ['network:dev-server', 'fs:.weft/browser'], available: true },
29
+ { name: 'run', description: 'Run a project task by name', args: { type: 'object', properties: { name: { type: 'string' } } }, sideEffects: ['task'], available: true },
30
+ { name: 'create', description: 'Scaffold a weft app', sideEffects: ['fs'], available: false },
31
+ { name: 'surfaces', description: "List the project's surfaces: name, kind, props and file", sideEffects: [], available: true },
32
+ { name: 'plugins', description: 'List and explain plugins and slot bindings; `sync` regenerates .weft/slots and plugin tokens; `audit` and `approve` check and write the permissions lock', args: { type: 'object', properties: { subcommand: { type: 'string', enum: ['list', 'explain', 'sync', 'audit', 'approve'] }, slot: { type: 'string' }, all: { type: 'boolean' } } }, sideEffects: ['fs:.weft/**', 'fs:.gitignore'], available: true },
33
+ ]
34
+
35
+ function jsonSchema(args) {
36
+ if (!args) return { type: 'object', properties: {} }
37
+ if ('_zod' in args || '~standard' in args) {
38
+ try {
39
+ return z.toJSONSchema(args)
40
+ } catch {
41
+ return { type: 'object', description: 'Zod schema that has no JSON Schema form' }
42
+ }
43
+ }
44
+ return args
45
+ }
46
+
47
+ /** `weft run <name>`: the task's command in a shell at the project root, its output yours. Resolves with its exit code. */
48
+ export async function runTask(root, name) {
49
+ root = resolve(root)
50
+ const { config } = await loadConfig(root)
51
+ const task = config.tasks?.[name]
52
+ if (!task) throw Object.assign(new Error(`no task "${name}" (tasks: ${Object.keys(config.tasks ?? {}).join(', ') || 'none'})`), { code: 'E_USAGE' })
53
+ return new Promise((ok, fail) => {
54
+ const child = spawn(task.run, { cwd: root, shell: true, stdio: 'inherit' })
55
+ child.on('error', fail)
56
+ child.on('close', code => ok(code ?? 1))
57
+ })
58
+ }
59
+
60
+ /** Everything `weft` can run here: the built-ins and the project's own tasks, with the config's diagnostics. */
61
+ export async function tasks(root) {
62
+ root = resolve(root)
63
+ const { file, config, diagnostics } = await loadConfig(root)
64
+ const environments = Object.keys(config.environments ?? {})
65
+ const stack = existsSync(join(root, 'src', 'routes'))
66
+ const list = builtins.map(b => ({
67
+ name: b.name,
68
+ kind: 'builtin',
69
+ command: { check: 'weft check --json', tasks: 'weft tasks --json', docs: 'weft docs <topic>' }[b.name] ?? `weft ${b.name}`,
70
+ description: b.description,
71
+ args: b.args ?? { type: 'object', properties: {} },
72
+ sideEffects: b.sideEffects,
73
+ env: environments,
74
+ available: b.available || (b.stack && stack),
75
+ ...(b.longRunning && { longRunning: true }),
76
+ }))
77
+ for (const [name, t] of Object.entries(config.tasks ?? {})) {
78
+ list.push({
79
+ name,
80
+ kind: 'project',
81
+ command: `weft run ${name}`,
82
+ description: t.description ?? null,
83
+ args: jsonSchema(t.args),
84
+ sideEffects: t.sideEffects ?? null,
85
+ env: t.env ?? environments,
86
+ available: true,
87
+ })
88
+ }
89
+ return {
90
+ version: 1,
91
+ ok: !diagnostics.some(d => d.severity === 'error'),
92
+ root,
93
+ config: file ? relative(root, file).split('\\').join('/') : null,
94
+ tasks: list,
95
+ scope: { write: config.scope?.write ?? null, read: config.scope?.read ?? null, forbid: config.scope?.forbid ?? [] },
96
+ environments,
97
+ diagnostics: diagnostics.filter(d => ['P003', 'P004', 'P007'].includes(d.code)),
98
+ }
99
+ }