@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/check.mjs ADDED
@@ -0,0 +1,328 @@
1
+ // `weft check`: compile every project file in check mode, add the project view, the X-codes (text-checks.mjs), plugin
2
+ // `check` hooks, the test T-codes, the config P-codes, the style D-codes (style.mjs) and the design D-codes (design-checks.mjs), and keep `.weft/styles.json`
3
+ // current when the project has style groups.
4
+
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
6
+ import { createRequire } from 'node:module'
7
+ import { isAbsolute, join, relative, resolve } from 'node:path'
8
+ import { pathToFileURL } from 'node:url'
9
+ import { diagnostic, locate, positions, sortDiagnostics, summarize } from './diag.mjs'
10
+ import { discover } from './files.mjs'
11
+ import { isDataConfig, loadConfig, writeConfigSchema } from './load-config.mjs'
12
+ import { sync } from './plugins.mjs'
13
+ import { writeMap } from './map.mjs'
14
+ import { projectView } from './project.mjs'
15
+ import { designChecks, hasTokens } from './design-checks.mjs'
16
+ import { classSoup, matrixDiagnostics, styleFiles, styleModule, usesTailwind, writeMatrixFile } from './style.mjs'
17
+ import { checkTests, isTestFile, testGlobs } from './tests.mjs'
18
+ import { pluginChecks } from './plugin-checks.mjs'
19
+ import { propChecks } from './props-checks.mjs'
20
+ import { reactChecks } from './react-checks.mjs'
21
+ import { trustDiagnostics } from './trust.mjs'
22
+ import { shareChecks } from './share-checks.mjs'
23
+ import { stateChecks } from './state-checks.mjs'
24
+ import { surfaceChecks } from './surface-checks.mjs'
25
+ import { textChecks } from './text-checks.mjs'
26
+ import { loadCompiler, resolveWeft } from './weft.mjs'
27
+ import { dbLayer } from './db-layer.mjs'
28
+ import { routeDiagnostics, schemaHash, siteOf, start } from './start.mjs'
29
+
30
+ const posix = p => p.split('\\').join('/')
31
+
32
+ /** Q015: the schema's hash against the one `weft db generate` recorded (research 28 §2). */
33
+ function schemaDrift(root, layer) {
34
+ const schema = layer?.schema && join(root, layer.schema)
35
+ if (!schema || !existsSync(schema)) return []
36
+ const file = join(root, layer.migrations, '.schema-hash')
37
+ if (existsSync(file) && readFileSync(file, 'utf8').trim() === schemaHash(readFileSync(schema))) return []
38
+ return [diagnostic({ code: 'Q015', file: layer.schema, why: existsSync(file) ? 'The schema changed since the last `weft db generate`: the database will not match it.' : 'No migrations yet: the first request that reads a table fails with Q014.' })]
39
+ }
40
+
41
+ /** I001–I003: the walk a build does (@weft-js/warp-icons/compile), when the project has the package. */
42
+ async function iconChecks(root) {
43
+ const require = createRequire(join(root, 'package.json'))
44
+ let compile
45
+ for (const from of [() => require.resolve('@weft-js/warp-icons/compile'), () => createRequire(require.resolve('@weft-js/warp'))
46
+ .resolve('@weft-js/warp-icons/compile')]) {
47
+ try { compile = from(); break } catch { /* not this way */ }
48
+ }
49
+ if (!compile) return []
50
+ const { findings } = await import(pathToFileURL(compile).href)
51
+ return findings(root).map(f => diagnostic({ code: f.code, file: f.file, span: [f.line, f.column, f.line, f.column], why: f.why }))
52
+ }
53
+
54
+ /** H010: what the last build or `weft dev` found keeps an island from waiting (`.weft/hydration.json`, @weft-js/core/hydration). */
55
+ function hydrationChecks(root) {
56
+ const file = join(root, '.weft', 'hydration.json')
57
+ if (!existsSync(file)) return []
58
+ try {
59
+ return JSON.parse(readFileSync(file, 'utf8')).diagnostics.filter(d => existsSync(join(root, d.file))).map(d => diagnostic({ code: d.code, file: d.file, span: [d.line, 1, d.line, 1], why: d.why, help: d.fix }))
60
+ } catch {
61
+ return []
62
+ }
63
+ }
64
+
65
+ /**
66
+ * H001–H007, H011–H016: page metadata, per route, on what the routes really produce (`inspect.mjs`). With `files`, only
67
+ * when a route is among them. An app that can't run here is still read: a check never fails for what it couldn't start.
68
+ */
69
+ async function routeChecks(root, loaded, given, reported) {
70
+ if (!existsSync(join(root, 'src', 'routes')) || loaded.config?.check?.routes === false || loaded.diagnostics.some(d => d.code === 'P007')) return []
71
+ if (given?.length && !given.some(f => f.startsWith('src/routes/'))) return []
72
+ let inspect
73
+ try { inspect = await start(root, 'inspect') } catch { return [] }
74
+ const keep = list => routeDiagnostics(root, list).filter(d => !(d.code === 'H001' && reported.has('H001')))
75
+ try {
76
+ return keep((await inspect.routesMeta({ root, site: siteOf(loaded) })).diagnostics)
77
+ } catch (error) {
78
+ return keep([...inspect.staticRouteChecks(root), { code: 'H016', severity: 'info', file: 'src/routes', route: '*', why: `The routes didn't load here, so only their source was checked: ${String(error?.message ?? error).split('\n')[0]}` }])
79
+ }
80
+ }
81
+
82
+ /**
83
+ * @param {string} root
84
+ * @param {{ files?: string[] }} [options] files relative to `root` (or absolute); default: discovered
85
+ */
86
+ export async function check(root, options = {}) {
87
+ const started = performance.now()
88
+ root = resolve(root)
89
+ const weft = resolveWeft(root)
90
+ const compiler = loadCompiler(weft)
91
+ const loaded = await loadConfig(root)
92
+ const { config } = loaded
93
+ // Slot modules and plugin token files stay current with the config (research 13 §2.6).
94
+ if (!loaded.diagnostics.some(d => d.code === 'P007') && (config.plugins?.length || existsSync(join(root, '.weft', 'slots')))) sync(root, loaded)
95
+
96
+ if (loaded.file && isDataConfig(loaded.file)) writeConfigSchema(root)
97
+ if (existsSync(join(root, '.weft'))) await writeMap(root)
98
+
99
+ const exclude = [...(config.check?.exclude ?? []), ...(config.scope?.forbid ?? [])]
100
+ const given = options.files?.map(f => posix(relative(root, isAbsolute(f) ? f : join(root, f))))
101
+ const include = config.check?.include ?? ['**/*.{tsx,jsx}']
102
+ const rels = given?.length ? given.filter(f => /\.[jt]sx$/.test(f) || !isTestFile(f)) : discover(root, { include, exclude })
103
+ const testRels = given?.length ? given.filter(isTestFile) : discover(root, { include: config.test?.include ?? testGlobs, exclude })
104
+
105
+ // Style groups (research 23): the project's definitions first, so each file compiles with the groups it imports.
106
+ const style = await styleModule(weft)
107
+ const sfiles = style ? styleFiles(root, config) : []
108
+ const scan = sfiles.some(f => readFileSync(join(root, f), 'utf8').includes('@weft-js/core/style')) ? style.scanProject(root, sfiles, (...a) => compiler.transform(...a)) : null
109
+
110
+ const files = []
111
+ const posByAbs = new Map()
112
+ for (const rel of rels) {
113
+ const abs = join(root, rel)
114
+ const text = readFileSync(abs, 'utf8')
115
+ const pos = positions(text)
116
+ posByAbs.set(abs, pos)
117
+ const groups = scan ? style.importedGroups(abs, text, f => scan.groups.filter(g => join(root, g.file) === f)) : {}
118
+ const out = compiler.transform(text, Object.keys(groups).length ? { filename: abs, checkOnly: true, groups } : { filename: rel, checkOnly: true })
119
+ const diagnostics = out.diagnostics.map(d => {
120
+ const from = pos.fromByte(d.start)
121
+ const end = pos.fromByte(d.end)
122
+ const fix = d.fix && {
123
+ description: d.fix.description,
124
+ safe: d.fix.safe,
125
+ edits: d.fix.edits.map(e => {
126
+ const a = pos.fromByte(e.start)
127
+ const b = pos.fromByte(e.end)
128
+ return { file: rel, span: pos.span(a, b), offset: [a, b], text: e.text }
129
+ }),
130
+ }
131
+ return diagnostic({ code: d.code, severity: d.severity, file: rel, span: pos.span(from, end), offset: [from, end], why: d.message, help: d.help, fix })
132
+ })
133
+ files.push({ abs, rel, text, diagnostics })
134
+ }
135
+
136
+ projectView(files, file => posByAbs.get(file.abs))
137
+
138
+ const matrix = scan && style.buildMatrix(scan.groups, scan.uses)
139
+ if (matrix && scan.groups.length) writeMatrixFile(root, matrix)
140
+ const styles = [...(usesTailwind(root) ? [] : classSoup(files.filter(f => /\.[jt]sx$/.test(f.rel)))), ...(matrix ? matrixDiagnostics(root, matrix) : [])]
141
+
142
+ const fontBuild = typeof loaded.slots['font.build'] === 'string' && !loaded.slots['font.build'].startsWith('weft:default')
143
+ const diagnostics = [
144
+ ...loaded.diagnostics,
145
+ ...(loaded.diagnostics.some(d => d.code === 'P007') ? [] : trustDiagnostics(root, loaded)),
146
+ ...files.flatMap(f => f.diagnostics),
147
+ ...styles,
148
+ ...(config.check?.design === false ? [] : designChecks(root, { files, exclude, tokens: hasTokens(root) })),
149
+ ...textChecks(root, { files, exclude, fontBuild }),
150
+ ...stateChecks(root, { files, exclude }),
151
+ ...shareChecks(root, { files }),
152
+ ...reactChecks({ files }),
153
+ ...surfaceChecks(root, { files, exclude, slots: loaded.slots }),
154
+ ...(await pluginChecks(root, loaded, { files, exclude })),
155
+ ...(await propChecks(root, config)),
156
+ ...checkTests(root, testRels),
157
+ ...(await iconChecks(root)),
158
+ ...hydrationChecks(root),
159
+ ...hygiene(root),
160
+ ...(await socketChecks(root)),
161
+ ...schemaDrift(root, await dbLayer(root, loaded)),
162
+ ...(await rustChecks(root, loaded)),
163
+ ...(await liveChecks(root, loaded, options.preset)),
164
+ ]
165
+ diagnostics.push(...await routeChecks(root, loaded, given, new Set(loaded.diagnostics.map(d => d.code))))
166
+ sortDiagnostics(diagnostics)
167
+ const summary = { files: files.length, tests: testRels.length, ...summarize(diagnostics) }
168
+ return {
169
+ version: 1,
170
+ ok: summary.errors === 0,
171
+ root,
172
+ weft: { version: weft.version, from: weft.from, compiler: weft.compilerPath },
173
+ config: loaded.file ? posix(relative(root, loaded.file)) : null,
174
+ summary,
175
+ diagnostics,
176
+ slots: loaded.slots,
177
+ styles: matrix ? { groups: Object.keys(matrix.groups).length, matrix: '.weft/styles.json' } : null,
178
+ files: rels,
179
+ tests: testRels,
180
+ ms: Math.round(performance.now() - started),
181
+ }
182
+ }
183
+
184
+ /**
185
+ * R101, R102 from the contract, and R103, R109 from `cargo check`, for an app with a sidecar: an agent gets the same
186
+ * answer here as from `weft build`, with the function's id on it.
187
+ */
188
+ async function rustChecks(root, loaded) {
189
+ let sidecar
190
+ try { sidecar = await start(root, 'sidecar') } catch { return [] }
191
+ if (!sidecar.usesRemote(root)) return []
192
+ const rust = loaded.config?.rust
193
+ const held = process.env.WEFT_BUILD
194
+ process.env.WEFT_BUILD = '1'
195
+ try {
196
+ const { writeContract } = await start(root, 'contract')
197
+ const preset = { node: 'node', bun: 'bun', deno: 'deno_server' }[loaded.config?.runtime ?? 'node']
198
+ const { contract, diagnostics } = await writeContract({ root, site: loaded.config?.site, rust, preset })
199
+ const out = diagnostics.filter(d => d.code.startsWith('R1')).map(d => diagnostic({ ...d, file: '.weft/contract.json' }))
200
+ if (out.some(d => d.severity === 'error')) return out
201
+ for (const service of Object.keys(contract.services ?? {})) {
202
+ if (rust?.services?.[service]?.external) continue
203
+ const dir = sidecar.crateDir(root, rust, service)
204
+ if (!existsSync(dir)) {
205
+ out.push(diagnostic({ code: 'R109', file: '.weft/contract.json', why: `\`rust://${service}\` has no crate at \`${posix(relative(root, dir))}\`.` }))
206
+ continue
207
+ }
208
+ const checked = await sidecar.cargo('check', { root, dir, service, contract })
209
+ out.push(...checked.diagnostics.map(({ rendered, ...d }) => diagnostic({ ...d, help: rendered })))
210
+ out.push(...untouchedQueries(root, dir, contract, service))
211
+ if (contract.services[service].kind !== 'native' || !checked.ok) continue
212
+ const wasm = await sidecar.checkWasm({ root, dir, service, contract, preset, rust })
213
+ out.push(...wasm.diagnostics.map(d => diagnostic(d)))
214
+ }
215
+ return out
216
+ } catch (e) {
217
+ return [diagnostic({ code: 'R109', severity: 'warning', file: '.weft/contract.json', why: `the contract couldn't be written, so the sidecars weren't checked: ${e.message.split('\n')[0]}` })]
218
+ } finally {
219
+ if (held === undefined) delete process.env.WEFT_BUILD
220
+ else process.env.WEFT_BUILD = held
221
+ }
222
+ }
223
+
224
+ /** R105 before anything runs: a cached query with tags whose Rust never mentions `touch`, or hands `ctx` to anything. */
225
+ function untouchedQueries(root, dir, contract, service) {
226
+ const sources = readdirSync(join(dir, 'src'), { recursive: true, withFileTypes: true }).filter(f => f.isFile() && f.name.endsWith('.rs')).map(f => join(f.parentPath, f.name))
227
+ const out = []
228
+ for (const f of contract.functions) {
229
+ if (f.impl?.service !== service || !f.cache || !(f.tags === 'dynamic' || f.tags?.length)) continue
230
+ const method = f.name.replace(/[A-Z]/g, (c, i) => `${i ? '_' : ''}${c.toLowerCase()}`)
231
+ for (const file of sources) {
232
+ const text = readFileSync(file, 'utf8')
233
+ const at = text.search(new RegExp(`\\bfn\\s+${method}\\s*\\(`))
234
+ if (at < 0) continue
235
+ const open = text.indexOf('{', at)
236
+ let depth = 0, end = open
237
+ for (; end < text.length; end++) {
238
+ if (text[end] === '{') depth++
239
+ if (text[end] === '}' && --depth === 0) break
240
+ }
241
+ const body = text.slice(open, end)
242
+ const line = text.slice(0, at).split('\n').length
243
+ if (!/\.touch\(|\(\s*&?ctx\b|,\s*&?ctx\b/.test(body)) out.push(diagnostic({ code: 'R105', severity: 'warning', file: posix(relative(root, file)), span: [line, 1, line, 1], why: `\`${f.id}\` is cached under tags and reports no table: nothing can tell whether they cover what it reads. Call \`ctx.touch("table")\`, or drop \`tags\` if it reads none.` }))
244
+ break
245
+ }
246
+ }
247
+ return out
248
+ }
249
+
250
+ /** R013, R014 and R018: what the deploy target does to live data. Only an app with `changes` or `required` loads anything. */
251
+ async function liveChecks(root, loaded, preset) {
252
+ const file = join(root, 'src/realtime.server.ts')
253
+ let target
254
+ try { target = await start(root, 'live-target') } catch { return [] }
255
+ const required = target.scanRequired(root)
256
+ if (!required.length && !target.literalSecrets(root).length && !(existsSync(file) && /\bchanges\s*[:,}]/.test(readFileSync(file, 'utf8')))) return []
257
+ const held = process.env.WEFT_BUILD
258
+ process.env.WEFT_BUILD = '1'
259
+ try {
260
+ const { readLivePlan } = await start(root, 'contract')
261
+ const plan = await readLivePlan({ root, site: loaded.config?.site, live: loaded.config?.live, preset: preset ?? { node: 'node', bun: 'bun', deno: 'deno_server' }[loaded.config?.runtime ?? 'node'] })
262
+ return plan.diagnostics.map(d => diagnostic({ ...d, file: d.file ?? required[0]?.file ?? 'src/realtime.server.ts' }))
263
+ } catch (e) {
264
+ return [diagnostic({ code: 'R014', severity: 'warning', file: 'src/realtime.server.ts', why: `the deploy target couldn't be checked: ${e.message.split('\n')[0]}` })]
265
+ } finally {
266
+ if (held === undefined) delete process.env.WEFT_BUILD
267
+ else process.env.WEFT_BUILD = held
268
+ }
269
+ }
270
+
271
+ /** R001 for a server module's `channel`, and R004 for a socket handler of the app's own (research 32 §1). */
272
+ async function socketChecks(root) {
273
+ const src = join(root, 'src')
274
+ if (!existsSync(src)) return []
275
+ const out = []
276
+ let withIds
277
+ for (const f of readdirSync(src, { recursive: true, withFileTypes: true })) {
278
+ if (!f.isFile() || !/\.[cm]?[jt]sx?$/.test(f.name) || f.name.endsWith('.d.ts')) continue
279
+ const abs = join(f.parentPath, f.name), rel = relative(root, abs).replaceAll('\\', '/'), text = readFileSync(abs, 'utf8')
280
+ const hand = /from\s+['"]crossws|\bdefineWebSocketHandler\b/.exec(text)
281
+ if (hand) out.push(diagnostic({ code: 'R004', file: rel, ...locate(text, hand[0]), why: 'A WebSocket handler of its own skips the Origin check, `authorize`, the limits and resume. Use `channel`.' }))
282
+ if (!/\b(channel|sharedState)\s*[(<]/.test(text) || !/^src\/server\/|\.server\.[cm]?[jt]sx?$/.test(rel)) continue
283
+ if (withIds === undefined) {
284
+ try { withIds = (await import(pathToFileURL(createRequire(join(root, 'package.json')).resolve('@weft-js/start/vite')).href)).withIds } catch { withIds = null }
285
+ }
286
+ try { withIds?.(text, abs, rel) } catch (e) {
287
+ if (e.code !== 'R001') continue
288
+ const line = +/:(\d+) /.exec(e.message)?.[1]
289
+ out.push(diagnostic({ code: 'R001', file: rel, span: [line, 1, line, 1], why: e.message.split('\n')[0].replace(/^R001: \S+ /, '') }))
290
+ }
291
+ }
292
+ return out
293
+ }
294
+
295
+ /** weft owns the connection: these bypass reconnects, resume and the fake transport (research 30 §8). */
296
+ const SOCKET_PACKAGES = ['ws', 'socket.io-client', 'reconnecting-websocket', '@microsoft/fetch-event-source']
297
+
298
+ /** P002 (`package.json` scripts that aren't `weft …`), P120 (weft plugins wired straight into Vite), R007 (socket libraries). */
299
+ function hygiene(root) {
300
+ const out = []
301
+ const pkgFile = join(root, 'package.json')
302
+ if (existsSync(pkgFile)) {
303
+ const text = readFileSync(pkgFile, 'utf8')
304
+ let scripts = {}, deps = {}
305
+ try {
306
+ const pkg = JSON.parse(text)
307
+ scripts = pkg.scripts ?? {}
308
+ deps = pkg.dependencies ?? {}
309
+ } catch {}
310
+ for (const name of SOCKET_PACKAGES) {
311
+ if (deps[name]) out.push(diagnostic({ code: 'R007', file: 'package.json', ...locate(text, `"${name}"`), why: `\`${name}\` is a dependency: its connection has no resume, no cleanup and no fake in @weft-js/core/test. Use \`stream\`, \`.live()\` or \`channel\`.` }))
312
+ }
313
+ for (const [name, cmd] of Object.entries(scripts)) {
314
+ if (!/^weft(\s|$)/.test(cmd.trim())) {
315
+ out.push(diagnostic({ code: 'P002', file: 'package.json', ...locate(text, `"${name}"`), why: `Script \`${name}\` runs \`${cmd}\`, not \`weft …\`: agents can't see what it does or what it touches.` }))
316
+ }
317
+ }
318
+ }
319
+ for (const name of ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs']) {
320
+ const file = join(root, name)
321
+ if (!existsSync(file)) continue
322
+ const text = readFileSync(file, 'utf8')
323
+ for (const m of text.matchAll(/from\s+['"](@weft-js\/(?:plugin-[\w-]+|text))/g)) {
324
+ out.push(diagnostic({ code: 'P120', file: name, ...locate(text, m[1]), why: `\`${m[1]}\` is registered in ${name}, which bypasses weft.config.ts.` }))
325
+ }
326
+ }
327
+ return out
328
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,332 @@
1
+ // Argument parsing and output for `weft`. JSON on stdout with --json, human text on stderr.
2
+ // Exit codes: 0 ok, 1 diagnostics with errors (a build refused on one, or a stale AGENTS.md under --check), 2 usage or internal error.
3
+
4
+ import { fix } from './fix.mjs'
5
+ import { writeTokens } from './tokens.mjs'
6
+ import { createRequire } from 'node:module'
7
+ import { existsSync } from 'node:fs'
8
+ import { join, resolve } from 'node:path'
9
+ import { pathToFileURL } from 'node:url'
10
+ import { addCommand } from './add.mjs'
11
+ import { writeAgentsMd } from './agents-md.mjs'
12
+ import { check } from './check.mjs'
13
+ import { formatHuman, sortDiagnostics } from './diag.mjs'
14
+ import { docs } from './docs.mjs'
15
+ import { compilerLine, doctor, formatDoctor } from './doctor.mjs'
16
+ import { formatMap, writeMap } from './map.mjs'
17
+ import { serve } from './mcp.mjs'
18
+ import { explain, list, sync } from './plugins.mjs'
19
+ import { approveCommand, auditCommand, formatTrust } from './trust.mjs'
20
+ import { loadConfig } from './load-config.mjs'
21
+ import { listSurfaces } from './surface-checks.mjs'
22
+ import { runTask, tasks } from './tasks.mjs'
23
+ import { formatGate, gate } from './gate.mjs'
24
+ import { browserCommand } from './browser/cli.mjs'
25
+ import { metaTable } from './meta-table.mjs'
26
+ import { audit } from './audit.mjs'
27
+ import { buildCommand, dbCommand, devCommand, doctorCommand, routesCommand, startCommand } from './start.mjs'
28
+ import { explainTarget, findGroups, formatExplain, formatFind, styleMatrix, writeMatrixFile } from './style.mjs'
29
+
30
+ const usage = `usage: weft <command> [--json] [--root <dir>]
31
+
32
+ check [files…] [--preset <p>] compile in check mode; W-, P- and D-diagnostics with fixes (--verbose: which compiler ran)
33
+ doctor [--preset <p>] which compiler this install uses, whether Rust is around (it's optional), and what live data does on the target
34
+ fix [files…] apply the safe autofixes
35
+ tokens warp's resolved tokens as DTCG, in .weft/tokens/
36
+ test --gate the test gate on the diff: fail-before (T010), mutants on changed lines (T011), no weakening (T020)
37
+ [--base <ref>] [--budget <sec>] [--max-mutants <n>] [--refactor] [--no-mutation]
38
+ browser <cmd> drive the running app (\`weft browser help\`): open, snapshot, act, assert, net, time, record…
39
+ lsp a language server for editors on TypeScript 7: tsc --lsp with inferred props
40
+ tsc type-check the whole project with inferred props (tsc or tsgo; for CI)
41
+ dev [--port <n>] the dev server for an app with src/routes (Vite + Nitro)
42
+ build [--runtime node|bun|deno] [--preset <p>] production build to .output, and .weft/contract.json; then the
43
+ built pages are audited (L-codes) and an error fails the build
44
+ [--unsafe-allow <codes>] go past these codes (R002, L003, a family like L, or all): forces a deploy
45
+ audit [routes…] [--url <u>] [--all] [--max <n>] the built pages, or a running site, in a clean Chrome at a phone's
46
+ and a desktop's width: axe-core's WCAG rules, console, deprecations, head, sideways scroll, shift
47
+ start [--runtime r] run .output/ on its runtime (the config's \`runtime\`, node by default)
48
+ db generate [name] a migration for the schema's changes (it applies on the next request)
49
+ db live a migration with change triggers for the tables in \`changes\`
50
+ db listen the change feed as its own process, for an app on functions
51
+ routes [--meta] the route table; --meta: every route's merged metadata, tags, card mode and H-diagnostics
52
+ tasks commands and project tasks
53
+ run <task> run a project task from weft.config.ts
54
+ docs [topic|code] docs for agents (rules, props, lists, async, testing, config, diagnostics, commands, W014…)
55
+ agents-md [--check] write AGENTS.md (--check: exit 1 when stale; --stdout: print it)
56
+ map [--stdout] write .weft/map.md and .json: a matrix of what to call in this app
57
+ add <feature> install a plugin and wire it into the config; also tailwind, auth, db [engine] [--layer drizzle|prisma|native], and rust [name] [--remote] [--dry-run] [--no-install]
58
+ plugins [list] plugins, the slots they provide and bind, permissions, diagnostics
59
+ plugins explain <slot> binding chain: config → plugin → module → .weft/slots/<id>.ts
60
+ plugins sync regenerate .weft/slots/*, .weft/slots.d.ts and .weft/tokens/*
61
+ plugins audit each plugin's permissions and code against .weft/plugins.lock.json, and what it uses undeclared
62
+ plugins approve [name…] [--all] record them in the lock; with no names, show what's waiting and write nothing
63
+ style find <query> style groups that fit what you're about to style (\`card\`, \`status badge\`): reuse before you write
64
+ style explain <group|selector> [--<axis> <value>] [--states a,b] [--accent intent:token] [--part p]
65
+ resolved CSS for one variation, and where each value came from
66
+ style matrix write .weft/styles.json (every group: axes, parts, accents, resolved CSS, uses)
67
+ mcp MCP server on stdio: docs, check, tasks, style_find, style_explain, test_gate, browser_*`
68
+
69
+ /** Flags that take a value (`--base main` or `--base=main`), and boolean flags, beyond the global ones. */
70
+ const valued = new Set(['layer', 'base', 'budget', 'max-mutants', 'concurrency', 'url', 'tab', 'out', 'component', 'from', 'props', 'name', 'scope', 'timeout', 'preset', 'port', 'runtime', 'unsafe-allow', 'max'])
71
+ /** `weft style explain` takes the group's own axes as flags (`--status late`); these are its fixed ones. */
72
+ const styleFlags = new Set(['states', 'accent', 'part'])
73
+ const booleans = new Set(['dry-run', 'no-install', 'remote', 'gate', 'refactor', 'no-mutation', 'interactive', 'delta', 'graph-only', 'no-graph', 'headed', 'full', 'reload', 'meta', 'all', 'verbose'])
74
+
75
+ export function parse(argv) {
76
+ const out = { command: argv[0], positional: [], json: false, root: process.cwd(), check: false, stdout: false, flags: {} }
77
+ for (let i = 1; i < argv.length; i++) {
78
+ const a = argv[i]
79
+ if (a === '--json') out.json = true
80
+ else if (a === '--check') out.check = true
81
+ else if (a === '--stdout') out.stdout = true
82
+ else if (a === '--root') out.root = resolve(argv[++i] ?? '.')
83
+ else if (a.startsWith('--root=')) out.root = resolve(a.slice(7))
84
+ else if (a.startsWith('--')) {
85
+ const [name, inline] = a.slice(2).split(/=(.*)/s)
86
+ if (valued.has(name)) out.flags[name] = inline ?? argv[++i]
87
+ else if (booleans.has(name) && inline === undefined) out.flags[name] = true
88
+ else if (out.command === 'style' && styleFlags.has(name)) out.flags[name] = inline ?? argv[++i]
89
+ else if (out.command === 'style' && /^[a-z][\w-]*$/i.test(name) && (inline ?? argv[i + 1]) !== undefined) (out.flags.axes ??= {})[name] = inline ?? argv[++i]
90
+ else throw Object.assign(new Error(`unknown flag ${a}`), { code: 'E_USAGE' })
91
+ } else out.positional.push(a)
92
+ }
93
+ return out
94
+ }
95
+
96
+ export async function main(argv, io = { stdout: process.stdout, stderr: process.stderr }) {
97
+ const print = data => io.stdout.write(`${JSON.stringify(data, null, 2)}\n`)
98
+ const say = text => text && io.stderr.write(`${text}\n`)
99
+ let args
100
+ try {
101
+ args = parse(argv)
102
+ switch (args.command) {
103
+ case 'lsp': {
104
+ const { lsp } = await import('./infer/lsp.mjs')
105
+ const out = lsp(args.root)
106
+ return await new Promise(ok => { out.server.on('exit', () => ok(0)) })
107
+ }
108
+ case 'tsc': {
109
+ const { propTsc } = await import('./props-checks.mjs')
110
+ const diagnostics = sortDiagnostics(await propTsc(args.root))
111
+ if (args.json) print({ ok: !diagnostics.some(d => d.severity === 'error'), diagnostics })
112
+ say(formatHuman(diagnostics))
113
+ say(`weft tsc: ${diagnostics.filter(d => d.severity === 'error').length} errors`)
114
+ return diagnostics.some(d => d.severity === 'error') ? 1 : 0
115
+ }
116
+ case 'check': {
117
+ const result = await check(args.root, { files: args.positional, preset: args.flags.preset })
118
+ if (args.json) print(result)
119
+ say(formatHuman(result.diagnostics))
120
+ if (args.flags.verbose) say(compilerLine(doctor(args.root).compiler))
121
+ say(`weft check: ${result.summary.files} files, ${result.summary.errors} errors, ${result.summary.warnings} warnings (${result.ms} ms)`)
122
+ return result.ok ? 0 : 1
123
+ }
124
+ case 'doctor': {
125
+ // What this install compiles with, always; what live data does on the target, for an app that has any.
126
+ const result = doctor(args.root)
127
+ const plan = args.flags.preset || existsSync(join(args.root, 'src/realtime.server.ts')) ? await doctorCommand(args.root, { preset: args.flags.preset, runtime: args.flags.runtime }) : undefined
128
+ if (args.json) print({ ...result, live: plan })
129
+ else say(formatDoctor(result))
130
+ if (!plan) return 0
131
+ say(`weft doctor: ${plan.preset} (${plan.host})\n live mode: ${plan.mode}\n ${plan.says}${plan.required.length ? `\n required: ${plan.required.join(', ')}` : ''}${plan.options.length ? `\n options:\n${plan.options.map(o => ` - ${o}`).join('\n')}` : ''}`)
132
+ say(formatHuman(plan.diagnostics))
133
+ return plan.diagnostics.some(d => d.severity === 'error') ? 1 : 0
134
+ }
135
+ case 'test': {
136
+ if (!args.flags.gate) throw Object.assign(new Error('`weft test` runs the gate with --gate; run the suite itself with vitest'), { code: 'E_USAGE' })
137
+ const num = name => (args.flags[name] === undefined ? undefined : Number(args.flags[name]))
138
+ const result = await gate(args.root, {
139
+ base: args.flags.base, budgetSec: num('budget'), maxMutants: num('max-mutants'), concurrency: num('concurrency'),
140
+ refactor: !!args.flags.refactor, mutation: !args.flags['no-mutation'], log: line => say(line),
141
+ })
142
+ if (args.json) print(result)
143
+ say(formatHuman(result.diagnostics))
144
+ say(formatGate(result))
145
+ say(`weft test --gate: ${result.ok ? 'pass' : 'fail'}, ${result.summary.changedTests} changed tests, ${result.summary.errors} errors, ${result.summary.warnings} warnings (${result.ms} ms)`)
146
+ return result.ok ? 0 : 1
147
+ }
148
+ case 'browser':
149
+ return await browserCommand(args, { print, say, io })
150
+ case 'dev': {
151
+ const server = await devCommand(args.root, { port: args.flags.port && Number(args.flags.port) })
152
+ say(`weft dev: ${server.resolvedUrls.local[0]}`)
153
+ await new Promise(ok => { process.once('SIGINT', ok) })
154
+ await server.close()
155
+ return 0
156
+ }
157
+ case 'build': {
158
+ const result = await buildCommand(args.root, { preset: args.flags.preset, runtime: args.flags.runtime, unsafeAllow: args.flags['unsafe-allow'] })
159
+ if (args.json) print(result)
160
+ say(formatHuman(result.diagnostics))
161
+ say(`weft build: ${result.preset} → ${result.output}, ${result.functions} functions in the contract, ${result.audited} pages audited (${result.ms} ms)`)
162
+ if (!result.ok) say('weft build: failed. The output is written, but fix the errors above before it ships, or `--unsafe-allow <code>` to force it.')
163
+ return result.ok ? 0 : 1
164
+ }
165
+ case 'audit': {
166
+ const result = await audit(args.root, { url: args.flags.url, routes: args.positional.length ? args.positional : undefined, all: !!args.flags.all, max: args.flags.max && Number(args.flags.max) })
167
+ if (args.json) print(result)
168
+ say(formatHuman(result.diagnostics))
169
+ say(`weft audit: ${result.skipped ?? `${result.pages.length} pages at a phone's and a desktop's width, ${result.diagnostics.length} findings (${result.ms} ms)`}`)
170
+ return result.ok ? 0 : 1
171
+ }
172
+ case 'start':
173
+ return await startCommand(args.root, { runtime: args.flags.runtime })
174
+ case 'db': {
175
+ const result = await dbCommand(args.root, args.positional[0], args.positional[1])
176
+ if (args.json) print(result)
177
+ say(result.output)
178
+ return 0
179
+ }
180
+ case 'routes': {
181
+ const result = await routesCommand(args.root, { meta: !!args.flags.meta, preset: args.flags.preset })
182
+ if (args.json) print(result)
183
+ else say(args.flags.meta ? metaTable(result) : result.routes.map(r => `${r.path.padEnd(24)} ${r.file}`).join('\n'))
184
+ say(formatHuman(result.diagnostics))
185
+ return result.ok ? 0 : 1
186
+ }
187
+ case 'surfaces': {
188
+ const surfaces = listSurfaces(args.root)
189
+ if (args.json) print({ surfaces })
190
+ else say(surfaces.length ? surfaces.map(x => `${String(x.name).padEnd(20)} ${String(x.kind).padEnd(10)} ${x.file}:${x.line} props: ${x.props.join(', ') || '(none)'}`).join('\n') : 'No surfaces yet: `weft docs surface`.')
191
+ return 0
192
+ }
193
+ case 'tasks': {
194
+ const result = await tasks(args.root)
195
+ if (args.json) print(result)
196
+ else say(result.tasks.map(t => `${t.available ? ' ' : '·'} ${t.name.padEnd(10)} ${t.description ?? ''}`).join('\n'))
197
+ say(formatHuman(result.diagnostics))
198
+ return result.ok ? 0 : 1
199
+ }
200
+ case 'run': {
201
+ if (!args.positional[0]) throw Object.assign(new Error('weft run <task>'), { code: 'E_USAGE' })
202
+ return await runTask(args.root, args.positional[0])
203
+ }
204
+ case 'docs': {
205
+ const page = await docs(args.root, args.positional[0])
206
+ if (args.json) print(page)
207
+ else io.stdout.write(`${page.text}\n`)
208
+ return 0
209
+ }
210
+ case 'agents-md': {
211
+ const out = await writeAgentsMd(args.root, { check: args.check || args.stdout })
212
+ if (args.stdout) io.stdout.write(out.text)
213
+ if (args.json) print({ path: out.path, bytes: out.bytes, limit: 8192, overLimit: out.overLimit, current: out.current, written: out.written })
214
+ say(`AGENTS.md: ${out.bytes} bytes${out.overLimit ? ' (over the 8 KB limit)' : ''}, ${out.written ? 'written' : out.current ? 'current' : 'stale'}`)
215
+ return (args.check && !out.current) || out.overLimit ? 1 : 0
216
+ }
217
+ case 'map': {
218
+ const map = await writeMap(args.root)
219
+ if (args.json) print(map)
220
+ else if (args.stdout) process.stdout.write(formatMap(map))
221
+ else say(`.weft/map.md: ${map.rows.length} rows`)
222
+ return 0
223
+ }
224
+ case 'icons': {
225
+ if (args.positional[0] !== 'publish' || !args.positional[1]) { say('usage: weft icons publish <dir>'); return 1 }
226
+ const { publish } = await import(pathToFileURL(createRequire(join(args.root, 'package.json')).resolve('@weft-js/warp-icons/compile')).href)
227
+ const out = await publish(resolve(args.root, args.positional[1]))
228
+ if (args.json) print(out)
229
+ else say(`${out.files} files for ${out.icons} icons in ${out.dir}. Nothing was uploaded: host that folder, then set icons({ source }).`)
230
+ return 0
231
+ }
232
+ case 'add': {
233
+ const result = await addCommand(args.root, args.positional[0], { dryRun: args.flags['dry-run'], install: !args.flags['no-install'], engine: args.positional[1], layer: args.flags.layer, remote: args.flags.remote })
234
+ if (args.json) print(result)
235
+ else say(result.already ? `${result.package} is already in ${result.config}` : result.actions.map(a => `${args.flags['dry-run'] ? 'would ' : ''}${a}`).join('\n'))
236
+ return 0
237
+ }
238
+ case 'plugins': {
239
+ const sub = args.positional[0] ?? 'list'
240
+ if (sub === 'list') {
241
+ const result = await list(args.root)
242
+ if (args.json) print(result)
243
+ else say(result.plugins.map(p => `${p.name}@${p.version ?? '?'} ${p.provides.map(s => `${s.slot}${s.key ? `[${s.key}]` : ''}${s.bound ? '' : ' (not bound)'}`).join(', ') || 'no slots'}${p.tokens ? ` tokens --${p.tokens}-*` : ''}`).join('\n') || 'no plugins')
244
+ say(formatHuman(result.diagnostics))
245
+ return result.ok ? 0 : 1
246
+ }
247
+ if (sub === 'explain') {
248
+ const result = await explain(args.root, args.positional[1])
249
+ if (args.json) print(result)
250
+ else say(JSON.stringify(result.binding, null, 2))
251
+ return 0
252
+ }
253
+ if (sub === 'sync') {
254
+ const loaded = await loadConfig(args.root)
255
+ const errors = loaded.diagnostics.filter(d => d.severity === 'error')
256
+ const written = loaded.diagnostics.some(d => d.code === 'P007') ? [] : sync(args.root, loaded)
257
+ if (args.json) print({ ok: errors.length === 0, written, diagnostics: loaded.diagnostics })
258
+ say(formatHuman(loaded.diagnostics))
259
+ say(`weft plugins sync: ${written.length} files written`)
260
+ return errors.length ? 1 : 0
261
+ }
262
+ if (sub === 'audit') {
263
+ const loaded = await loadConfig(args.root)
264
+ const result = auditCommand(args.root, loaded)
265
+ if (args.json) print(result)
266
+ else say(result.plugins.map(formatTrust).join('\n') || 'no plugins')
267
+ say(formatHuman(result.diagnostics))
268
+ return result.ok ? 0 : 1
269
+ }
270
+ if (sub === 'approve') {
271
+ const result = approveCommand(args.root, await loadConfig(args.root), { names: args.positional.slice(1), all: !!args.flags.all })
272
+ if (args.json) print(result)
273
+ else say([...(result.written ? result.approved : result.pending).map(formatTrust), result.written ? `${result.lock}: ${result.approved.length} approved` : result.pending.length ? 'nothing written: name a plugin, or pass --all' : 'nothing waiting', ...result.written ? result.pending.map(p => `still waiting: ${p.name}`) : [], ...(result.gitignore ? ['.gitignore: the lock is no longer ignored, so commit it'] : [])].join('\n'))
274
+ return result.ok ? 0 : 1
275
+ }
276
+ throw Object.assign(new Error(`unknown plugins subcommand ${String(sub)}`), { code: 'E_USAGE' })
277
+ }
278
+ case 'fix': {
279
+ const result = await fix(args.root, { files: args.positional })
280
+ if (args.json) print(result)
281
+ else say(result.applied.length ? result.applied.map(a => `${a.file}:${a.span[0]}:${a.span[1]} ${a.code}`).join('\n') : 'nothing to fix')
282
+ return 0
283
+ }
284
+ case 'tokens': {
285
+ const result = await writeTokens(args.root)
286
+ if (args.json) print(result)
287
+ else say(`${result.dir}: ${result.files.length} files`)
288
+ return 0
289
+ }
290
+ case 'style': {
291
+ const sub = args.positional[0]
292
+ if (sub === 'find') {
293
+ const result = findGroups((await styleMatrix(args.root)).matrix, args.positional.slice(1).join(' '))
294
+ if (args.json) print(result)
295
+ else say(formatFind(result))
296
+ return 0
297
+ }
298
+ if (sub === 'explain') {
299
+ const accent = args.flags.accent ? Object.fromEntries(args.flags.accent.split(/[\s,]+/).filter(Boolean).map(w => w.split(':'))) : undefined
300
+ const states = args.flags.states?.split(',').map(s => s.trim()).filter(Boolean)
301
+ const result = explainTarget(await styleMatrix(args.root), args.positional.slice(1).join(' '), { axes: args.flags.axes, states, accent, part: args.flags.part })
302
+ if (args.json) print(result)
303
+ else say(formatExplain(result))
304
+ return 0
305
+ }
306
+ if (sub === 'matrix') {
307
+ const { matrix } = await styleMatrix(args.root)
308
+ const written = args.stdout ? false : writeMatrixFile(args.root, matrix)
309
+ if (args.stdout || args.json) print(matrix)
310
+ say(`.weft/styles.json: ${Object.keys(matrix.groups).length} groups, ${matrix.diagnostics.length} diagnostics, ${args.stdout ? 'not written' : written ? 'written' : 'current'}`)
311
+ return 0
312
+ }
313
+ throw Object.assign(new Error(`weft style ${sub ?? ''}: use find <query>, explain <group|selector> or matrix`), { code: 'E_USAGE' })
314
+ }
315
+ case 'mcp':
316
+ await serve(args.root)
317
+ return 0
318
+ case undefined:
319
+ case 'help':
320
+ case '--help':
321
+ say(usage)
322
+ return args.command ? 0 : 2
323
+ default:
324
+ throw Object.assign(new Error(`unknown command ${args.command}`), { code: 'E_USAGE' })
325
+ }
326
+ } catch (error) {
327
+ const code = error.code ?? 'E_INTERNAL'
328
+ if (args?.json || argv.includes('--json')) print({ ok: false, error: { code, message: error.message } })
329
+ say(`weft: ${error.message}${code === 'E_USAGE' ? `\n\n${usage}` : ''}`)
330
+ return code === 'E_REFUSED' ? 1 : 2
331
+ }
332
+ }