@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
package/src/start.mjs ADDED
@@ -0,0 +1,296 @@
1
+ // `weft dev`, `weft build` and `weft routes` for an app with src/routes: they run @weft-js/start (research 25 §6–7).
2
+ // @weft-js/start resolves from the app first (its own version), then from this workspace.
3
+
4
+ import { audit, builtPublic } from './audit.mjs'
5
+ import { writeMap } from './map.mjs'
6
+ import { spawn, spawnSync } from 'node:child_process'
7
+ import { createHash } from 'node:crypto'
8
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { createRequire } from 'node:module'
10
+ import { dirname, join } from 'node:path'
11
+ import { pathToFileURL } from 'node:url'
12
+ import { parseEnv } from 'node:util'
13
+ import { dbLayer } from './db-layer.mjs'
14
+ import { diagnostic, locate, positions } from './diag.mjs'
15
+ import { loadConfig } from './load-config.mjs'
16
+ import { sync } from './plugins.mjs'
17
+ import { requireTrust } from './trust.mjs'
18
+ import { attachProps } from './props-listener.mjs'
19
+ import { host, presetFor } from './deploy/hosts.mjs'
20
+
21
+ export async function start(root, sub) {
22
+ let file
23
+ try {
24
+ file = createRequire(join(root, 'package.json')).resolve(`@weft-js/start/${sub}`)
25
+ } catch {
26
+ const sibling = new URL(`../../start/src/${sub}.mjs`, import.meta.url)
27
+ if (!existsSync(sibling)) throw Object.assign(new Error('@weft-js/start is not installed in this project'), { code: 'E_USAGE' })
28
+ file = sibling
29
+ }
30
+ return import(typeof file === 'string' ? pathToFileURL(file).href : file.href)
31
+ }
32
+
33
+ /** The config's `site` (metadata defaults), and the config diagnostics. Slot files are synced first, so @weft-js/core/vite
34
+ * finds the build-side providers (`.weft/slots/<id>.build.json`, research 26 §3.1). */
35
+ async function site(root) {
36
+ const loaded = await loadConfig(root)
37
+ if (!loaded.diagnostics.some(d => d.code === 'P007') && loaded.config?.plugins?.length) sync(root, loaded)
38
+ // A build on a config that doesn't load would quietly drop every plugin: fonts, tokens and slots.
39
+ const bad = loaded.diagnostics.find(d => d.code === 'P007')
40
+ if (bad) throw Object.assign(new Error(`P007: ${bad.message ?? bad.why}`), { code: 'E_REFUSED' }) // the config's fault, not the command's
41
+ return { site: siteOf(loaded), hydration: loaded.config?.hydration, diagnostics: loaded.diagnostics }
42
+ }
43
+
44
+ /** `site` with what plugins know about the app's look (`card` in a manifest): warp's colours, the text plugin's faces. */
45
+ export function siteOf(loaded) {
46
+ const base = loaded.config?.site ?? {}
47
+ const given = (loaded.contributions ?? []).map(c => c.manifest.card).filter(Boolean)
48
+ if (!given.length) return base
49
+ const card = Object.assign({}, ...given)
50
+ return { ...base, card: { ...card, ...base.card, fonts: { ...card.fonts, ...base.card?.fonts } } }
51
+ }
52
+
53
+ /** `runtime` in weft.config.ts or `--runtime`, as a Nitro preset (research 28 §5). */
54
+ export const PRESETS = { node: 'node', bun: 'bun', deno: 'deno_server' }
55
+ /** With no `runtime` in the config, the one running this: a machine may have only that one. */
56
+ export const hostRuntime = () => (process.versions.deno ? 'deno' : process.versions.bun ? 'bun' : 'node')
57
+
58
+ /** `.env` under what's already set. Not `process.loadEnvFile`: on Deno the file wins. */
59
+ function loadEnv(root) {
60
+ const file = join(root, '.env')
61
+ if (existsSync(file)) for (const [key, value] of Object.entries(parseEnv(readFileSync(file, 'utf8')))) process.env[key] ??= value
62
+ }
63
+
64
+ export async function devCommand(root, { port } = {}) {
65
+ // Production takes the host's environment; dev takes `.env`. What's already set wins.
66
+ loadEnv(root)
67
+ requireTrust(root, await loadConfig(root))
68
+ const { dev } = await start(root, 'app')
69
+ await writeMap(root)
70
+ const config = await site(root)
71
+ const rust = (await loadConfig(root)).config?.rust
72
+ const { devSidecars, usesRemote } = await start(root, 'sidecar')
73
+ // Before the dev server: its worker inherits the addresses, and a request can't reach a sidecar that's still building.
74
+ const sidecars = usesRemote(root) ? await devSidecars({ root, site: config.site, rust }) : undefined
75
+ const server = await dev({ root, ...config, port })
76
+ if (server.watcher) server.weftProps = attachProps(server, root, (await loadConfig(root)).config)
77
+ if (sidecars) {
78
+ sidecars.attach(server)
79
+ const close = server.close.bind(server)
80
+ server.close = async () => (await sidecars.close(), close())
81
+ }
82
+ return server
83
+ }
84
+
85
+ /**
86
+ * `--unsafe-allow` and `build.unsafeAllow`: codes (or a family, `L`, or `all`) a build reports and goes on past instead
87
+ * of failing on. For forcing a deploy you have looked at; each one it lets through is listed, as a warning, every build.
88
+ */
89
+ export function unsafeAllowed(flag, config) {
90
+ const reasons = config?.build?.unsafeAllow ?? {}
91
+ const codes = [...String(flag ?? '').split(','), ...Object.keys(reasons)].map(c => c.trim()).filter(Boolean)
92
+ const allows = d => codes.some(c => c === 'all' || c === d.code || (/^[A-Z]$/.test(c) && d.code.startsWith(c)))
93
+ const reason = d => reasons[d.code] ?? reasons[d.code[0]] ?? reasons.all ?? (flag ? '--unsafe-allow' : '')
94
+ return { allows, let: d => ({ ...d, severity: 'warning', why: `UNSAFE, let through (${reason(d)}): ${d.why}` }) }
95
+ }
96
+
97
+ export async function buildCommand(root, { preset, runtime, unsafeAllow } = {}) {
98
+ requireTrust(root, await loadConfig(root))
99
+ const unsafe = unsafeAllowed(unsafeAllow, (await loadConfig(root)).config)
100
+ const forced = []
101
+ const { build } = await start(root, 'app')
102
+ const { writeContract } = await start(root, 'contract')
103
+ const t = performance.now()
104
+ const config = await site(root)
105
+ const chosen = runtime ?? (await loadConfig(root)).config?.runtime ?? hostRuntime()
106
+ // `deploy.target` picks the preset when nothing else does: a build for Fly is the runtime's server, for Netlify `netlify`
107
+ const target = !preset && !runtime && (await loadConfig(root)).config?.deploy?.target
108
+ if (target) preset = presetFor(host(target), chosen)
109
+ if (!preset && !PRESETS[chosen]) throw Object.assign(new Error(`unknown runtime "${chosen}": use ${Object.keys(PRESETS).join(', ')}`), { code: 'E_USAGE' })
110
+ preset ??= PRESETS[chosen]
111
+ // A build holds no secrets: an app's env check stands down, and server modules load without a database.
112
+ const held = process.env.WEFT_BUILD
113
+ process.env.WEFT_BUILD = '1'
114
+ try {
115
+ const rust = (await loadConfig(root)).config?.rust
116
+ const refuse = d => {
117
+ if (unsafe.allows(d)) return void forced.push(unsafe.let(d))
118
+ // a finding, not a misuse: exit 1 with the fix and the way past it, never the usage text
119
+ throw Object.assign(new Error(`${d.code}: ${d.why}${d.rendered ? `\n${d.rendered}` : ''}\nTo build anyway: --unsafe-allow ${d.code}, or build.unsafeAllow in weft.config.ts with why.`), { code: 'E_REFUSED' })
120
+ }
121
+ // What the preset can't do fails now, not after a build: a verify runs at the end, against a target that's up.
122
+ const { readLivePlan } = await start(root, 'contract')
123
+ const plan = await readLivePlan({ root, site: config.site, preset, live: (await loadConfig(root)).config?.live, verify: false })
124
+ const unlive = plan.diagnostics.find(d => d.severity === 'error')
125
+ if (unlive) refuse(unlive)
126
+ const { buildSidecars, shipSidecars, startSidecars, usesRemote } = await start(root, 'sidecar')
127
+ const contracted = () => writeContract({ root, site: config.site, preset, rust })
128
+ let warned = []
129
+ // Sidecars first: prerendering a share card runs its route's `load`, which may read one.
130
+ const running = usesRemote(root) ? await contracted().then(async c => {
131
+ const stop = c.diagnostics.find(d => d.severity === 'error')
132
+ if (stop) refuse(stop)
133
+ const built = await buildSidecars({ root, contract: c.contract, rust, preset })
134
+ const failed = built.diagnostics.find(d => d.severity === 'error')
135
+ if (failed) refuse(failed)
136
+ warned = built.diagnostics
137
+ return startSidecars({ root, built: true, log: () => {} })
138
+ }) : undefined
139
+ const before = { ...process.env }
140
+ Object.assign(process.env, running?.env)
141
+ let output
142
+ try {
143
+ ({ output } = await build({ root, ...config, preset, logLevel: 'warn' }))
144
+ } finally {
145
+ running?.close()
146
+ for (const k of Object.keys(running?.env ?? {})) before[k] === undefined ? delete process.env[k] : (process.env[k] = before[k])
147
+ }
148
+ const contract = await writeContract({ root, site: config.site, preset, rust, live: (await loadConfig(root)).config?.live, verify: true })
149
+ for (const refused of contract.diagnostics.filter(d => d.severity === 'error')) refuse(refused)
150
+ if (running) shipSidecars(root)
151
+ await writeMap(root)
152
+ // Last, the pages as built, in a clean Chrome at a phone's width and a desktop's (audit.mjs): an error fails the build.
153
+ const settings = (await loadConfig(root)).config?.build?.audit ?? true
154
+ const audited = settings === false
155
+ ? { diagnostics: [diagnostic({ code: 'L000', severity: 'warning', file: 'weft.config.ts', why: '`build.audit` is false: the built pages were not audited.' })] }
156
+ : await audit(root, { dir: builtPublic(root), ...(typeof settings === 'object' && settings) })
157
+ const pages = audited.diagnostics.map(d => (d.severity === 'error' && unsafe.allows(d) ? unsafe.let(d) : d))
158
+ const kept = [...contract.diagnostics.filter(d => !(d.severity === 'error' && unsafe.allows(d))), ...warned].map(d => diagnostic({ file: '.weft/contract.json', ...d }))
159
+ const diagnostics = [...kept, ...pages, ...forced.map(d => diagnostic({ file: '.weft/contract.json', ...d }))]
160
+ return { ok: !diagnostics.some(d => d.severity === 'error'), preset, output, contract: contract.path, functions: contract.functions, audited: audited.pages?.length ?? 0, diagnostics, ms: Math.round(performance.now() - t) }
161
+ } finally {
162
+ if (held === undefined) delete process.env.WEFT_BUILD
163
+ else process.env.WEFT_BUILD = held
164
+ }
165
+ }
166
+
167
+ /** The built server's command for each runtime: `.env` for a local run, the host's environment otherwise. */
168
+ const SERVERS = { node: ['node', ['--disable-warning=ExperimentalWarning']], bun: ['bun', ['run']], deno: ['deno', ['run', '-A']] }
169
+
170
+ /** `weft start`: run `.output/` on the runtime it was built for. Resolves with the exit code. */
171
+ export async function startCommand(root, { runtime } = {}) {
172
+ const chosen = runtime ?? (await loadConfig(root)).config?.runtime ?? hostRuntime()
173
+ if (!SERVERS[chosen]) throw Object.assign(new Error(`unknown runtime "${chosen}": use ${Object.keys(SERVERS).join(', ')}`), { code: 'E_USAGE' })
174
+ loadEnv(root)
175
+ const [cmd, flags] = SERVERS[chosen]
176
+ // Sidecars first: the server's environment has their addresses, and they outlive a crash of either side.
177
+ const { startSidecars } = await start(root, 'sidecar')
178
+ const sidecars = await startSidecars({ root })
179
+ // The running binary when it can be: Deno on Windows doesn't find itself on PATH.
180
+ const child = spawn(chosen === hostRuntime() ? process.execPath : cmd, [...flags, join('.output', 'server', 'index.mjs')], { cwd: root, stdio: 'inherit', env: { ...process.env, ...sidecars.env } })
181
+ process.once('SIGINT', () => child.kill('SIGINT'))
182
+ process.once('SIGTERM', () => child.kill('SIGTERM'))
183
+ return new Promise(ok => { child.on('close', code => { sidecars.close(); ok(code ?? 0) }) })
184
+ }
185
+
186
+ /** The hash `weft db generate` records and Q015 compares. */
187
+ export const schemaHash = text => createHash('sha256').update(text).digest('hex').slice(0, 16)
188
+
189
+ /** What `weft db` takes. There's no `migrate`: new migrations apply on the next request. */
190
+ export const DB_COMMANDS = ['generate', 'live', 'listen']
191
+
192
+ /** `weft db generate [name]`: the data layer's migration, on Node like all of dev (research 28 §2, 34 §4). */
193
+ export async function dbCommand(root, sub, name) {
194
+ if (!DB_COMMANDS.includes(sub)) throw Object.assign(new Error(`weft db ${sub ?? ''}: use generate (new migrations apply on the next request), live or listen`), { code: 'E_USAGE' })
195
+ if (sub === 'live') return dbLive(root)
196
+ if (sub === 'listen') return dbListen(root)
197
+ const layer = await dbLayer(root)
198
+ if (!layer) throw Object.assign(new Error('this app has no database: `weft add db` adds one'), { code: 'E_USAGE' })
199
+ const output = layer.generate(root, name)
200
+ const schema = layer.schema && join(root, layer.schema)
201
+ if (schema && existsSync(schema)) writeFileSync(join(root, layer.migrations, '.schema-hash'), schemaHash(readFileSync(schema)) + '\n')
202
+ return { ok: true, output }
203
+ }
204
+
205
+ /**
206
+ * Route findings as diagnostics, pointing at the `meta` export, or at where one goes. H011's fix is that export, as an
207
+ * edit an agent can apply: not safe, since the words are a start and not the page's best.
208
+ */
209
+ export function routeDiagnostics(root, found) {
210
+ const text = f => { try { return readFileSync(join(root, f), 'utf8') } catch { return '' } }
211
+ return found.map(({ code, severity, file, route, why, help }) => {
212
+ const source = text(file)
213
+ const at = code === 'H011' && help?.startsWith('export const') && !/\bexport\s+(?:const|function|async function)\s+meta\b/.test(source) ? source.indexOf('export default') : -1
214
+ const place = positions(source)
215
+ const fix = at >= 0 && { fix: { safe: false, description: 'Add `meta`', edits: [{ file, span: place.span(at, at), offset: [at, at], text: `${help}\n\n` }] } }
216
+ return { ...diagnostic({ code, severity, file, why, help, ...locate(source, 'meta'), ...fix }), route }
217
+ })
218
+ }
219
+
220
+ /** `weft db live`: a migration that adds change triggers to the tables in `changes` (Postgres, SQLite, MySQL). */
221
+ async function dbLive(root) {
222
+ const held = process.env.WEFT_BUILD
223
+ process.env.WEFT_BUILD = '1'
224
+ const config = await site(root)
225
+ const { withRealtime } = await start(root, 'contract')
226
+ const { changeSql } = await start(root, 'changes-sql')
227
+ let sql, tables
228
+ try {
229
+ ({ sql, tables } = await withRealtime({ root, site: config.site }, async ({ changes }) => {
230
+ if (!changes?.defs) throw Object.assign(new Error('`src/realtime.server.ts` has no `changes: dbChanges(db, [tables])`'), { code: 'E_USAGE' })
231
+ const defs = await changes.defs()
232
+ return { sql: changeSql(changes.dialect, defs), tables: defs.map(t => t.name) }
233
+ }))
234
+ } finally {
235
+ if (held === undefined) delete process.env.WEFT_BUILD
236
+ else process.env.WEFT_BUILD = held
237
+ }
238
+ const layer = await dbLayer(root)
239
+ if (layer?.layer !== 'drizzle') return { ok: true, output: `${sql}\n-- add this to a migration of your data layer` }
240
+ const req = createRequire(join(root, 'package.json'))
241
+ const bin = join(dirname(req.resolve('drizzle-kit')), 'bin.cjs')
242
+ const r = spawnSync(process.execPath, [bin, 'generate', '--custom', '--name=weft_live'], { cwd: root, encoding: 'utf8' })
243
+ if (r.status !== 0) throw Object.assign(new Error(`drizzle-kit generate failed:\n${r.stdout}${r.stderr}`), { code: 'E_INTERNAL' })
244
+ const { entries } = JSON.parse(readFileSync(join(root, layer.migrations, 'meta', '_journal.json'), 'utf8'))
245
+ const file = join(layer.migrations, `${entries.at(-1).tag}.sql`)
246
+ writeFileSync(join(root, file), sql)
247
+ return { ok: true, output: `${file}: change triggers for ${tables.join(', ')}. They apply on the next request.` }
248
+ }
249
+
250
+ /** `weft db listen`: the change feed as its own process, for an app on functions. Runs until SIGINT or SIGTERM. */
251
+ async function dbListen(root) {
252
+ if (existsSync(join(root, '.env'))) process.loadEnvFile(join(root, '.env'))
253
+ const config = await site(root)
254
+ const { withRealtime } = await start(root, 'contract')
255
+ await withRealtime({ root, site: config.site }, async ({ changes, bus }, server) => {
256
+ if (!changes) throw Object.assign(new Error('`src/realtime.server.ts` has no `changes`'), { code: 'E_USAGE' })
257
+ const { listen } = await server.ssrLoadModule('@weft-js/start/listen')
258
+ const stop = await listen({ changes, bus: bus ?? { name: 'memory' } })
259
+ await new Promise(ok => { process.once('SIGINT', ok); process.once('SIGTERM', ok) })
260
+ await stop()
261
+ })
262
+ return { ok: true, output: 'weft db listen: stopped' }
263
+ }
264
+
265
+ /** `weft doctor`: what live data does on this deploy target, and what to do about what it can't. */
266
+ export async function doctorCommand(root, { preset, runtime } = {}) {
267
+ const config = await site(root)
268
+ const chosen = runtime ?? (await loadConfig(root)).config?.runtime ?? 'node'
269
+ const held = process.env.WEFT_BUILD
270
+ process.env.WEFT_BUILD = '1'
271
+ try {
272
+ const { readLivePlan } = await start(root, 'contract')
273
+ const plan = await readLivePlan({ root, site: config.site, preset: preset ?? PRESETS[chosen], live: (await loadConfig(root)).config?.live, verify: false })
274
+ return { ...plan, diagnostics: plan.diagnostics.map(d => diagnostic({ file: 'src/realtime.server.ts', ...d })) }
275
+ } finally {
276
+ if (held === undefined) delete process.env.WEFT_BUILD
277
+ else process.env.WEFT_BUILD = held
278
+ }
279
+ }
280
+
281
+ /** Every route's merged metadata, tags and card mode (`--meta`), or just the route table. */
282
+ export async function routesCommand(root, { meta, preset } = {}) {
283
+ const config = await site(root)
284
+ if (meta) {
285
+ const { routesMeta } = await start(root, 'inspect')
286
+ const result = await routesMeta({ root, site: config.site, preset })
287
+ return { ...result, diagnostics: [...config.diagnostics, ...routeDiagnostics(root, result.diagnostics)] }
288
+ }
289
+ const { scanRoutes, filePath } = await start(root, 'routes')
290
+ const manifest = scanRoutes(root)
291
+ return {
292
+ version: 1, ok: true, problems: manifest.problems,
293
+ routes: manifest.routes.map(r => ({ path: r.kind === 'page' ? filePath(manifest, r) : r.kind, kind: r.kind, file: r.file, layouts: r.layouts, params: r.params, card: r.card ?? null })),
294
+ diagnostics: config.diagnostics,
295
+ }
296
+ }
@@ -0,0 +1,213 @@
1
+ // S001 and S012 in `weft check` (research 14 §3.1, §3.4, research 29 §4): server values copied into state, and
2
+ // `state.linked` over one with no `key`. X037: a resource or query made beside the `<Boundary>` meant to wait for it.
3
+ // These need to know which imports are queries, so they read the server modules; the compiler sees one file at a time. TypeScript's parser only, no type checking, and a name's taint lasts for the
4
+ // function it was declared in, so a same-named primitive elsewhere isn't flagged.
5
+
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+ import { join, relative } from 'node:path'
8
+ import ts from 'typescript'
9
+ import { local } from './alias.mjs'
10
+ import { diagnostic, positions } from './diag.mjs'
11
+ import { discover } from './files.mjs'
12
+
13
+ const isServerModule = rel => rel.startsWith('src/server/') || /\.server\.[cm]?[jt]sx?$/.test(rel)
14
+ const KEEPS_OBJECTS = new Set(['map', 'filter', 'flatMap', 'slice', 'sort', 'toSorted', 'reverse', 'concat', 'find', 'at', 'structuredClone'])
15
+ const ESCAPES = new Set(['untrack', 'optimistic'])
16
+
17
+ /** `export const name = query(` in a module: the names of its queries. */
18
+ const queriesOf = text => new Set([...text.matchAll(/export\s+const\s+([A-Za-z_$][\w$]*)\s*=\s*query\s*\(/g)].map(m => m[1]))
19
+
20
+ function moduleFile(root, from, spec) {
21
+ const abs = local(join(root, from), spec)
22
+ if (!abs) return undefined
23
+ const base = relative(root, abs).split('\\').join('/').replace(/\.js$/, '')
24
+ return ['', '.ts', '.tsx', '.mts', '.js', '/index.ts'].map(x => base + x).find(f => /\.[cm]?[jt]sx?$/.test(f) && existsSync(join(root, f)))
25
+ }
26
+
27
+ /** Local names of the queries `sf` imports from server modules. */
28
+ function importedQueries(root, rel, sf, cache) {
29
+ const out = new Set()
30
+ for (const n of sf.statements) {
31
+ if (!ts.isImportDeclaration(n) || !n.importClause?.namedBindings || !ts.isNamedImports(n.importClause.namedBindings)) continue
32
+ const file = moduleFile(root, rel, n.moduleSpecifier.text)
33
+ if (!file || !isServerModule(file)) continue
34
+ if (!cache.has(file)) cache.set(file, queriesOf(readFileSync(join(root, file), 'utf8')))
35
+ for (const s of n.importClause.namedBindings.elements) if (cache.get(file).has((s.propertyName ?? s.name).text)) out.add(s.name.text)
36
+ }
37
+ return out
38
+ }
39
+
40
+ function scan(rel, text, queries, report) {
41
+ const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true, /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
42
+ const frames = [{ handles: new Set(), values: new Set() }]
43
+ const has = (kind, name) => frames.some(f => f[kind].has(name))
44
+ const peel = e => { while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e)) e = e.expression; return e }
45
+
46
+ const isQueryCall = e => ts.isCallExpression(e) && (ts.isIdentifier(e.expression) ? queries.has(e.expression.text) : ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === 'live' && ts.isIdentifier(e.expression.expression) && queries.has(e.expression.expression.text))
47
+ const isFetch = e => ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === 'fetch' && ts.isIdentifier(e.expression.expression) && queries.has(e.expression.expression.text)
48
+ const isHandle = e => { e = peel(e); return isQueryCall(e) || (ts.isIdentifier(e) && has('handles', e.text)) }
49
+
50
+ /** The expression holds a whole server object or array, or a derived copy of one. A picked field isn't one. */
51
+ function serverValue(e) {
52
+ e = peel(e)
53
+ if (ts.isIdentifier(e)) return has('values', e.text)
54
+ if (ts.isAwaitExpression(e)) return isFetch(peel(e.expression)) || isQueryCall(peel(e.expression))
55
+ if (ts.isPropertyAccessExpression(e)) return e.name.text === 'value' && isHandle(e.expression)
56
+ if (ts.isElementAccessExpression(e)) return serverValue(e.expression)
57
+ if (ts.isBinaryExpression(e) && [ts.SyntaxKind.QuestionQuestionToken, ts.SyntaxKind.BarBarToken].includes(e.operatorToken.kind)) return serverValue(e.left) || serverValue(e.right)
58
+ if (ts.isConditionalExpression(e)) return serverValue(e.whenTrue) || serverValue(e.whenFalse)
59
+ if (ts.isCallExpression(e)) {
60
+ const c = e.expression
61
+ return ts.isPropertyAccessExpression(c) && KEEPS_OBJECTS.has(c.name.text) && serverValue(c.expression)
62
+ }
63
+ if (ts.isArrayLiteralExpression(e)) return e.elements.some(x => ts.isSpreadElement(x) && serverValue(x.expression))
64
+ if (ts.isObjectLiteralExpression(e)) return e.properties.some(p => ts.isSpreadAssignment(p) && serverValue(p.expression))
65
+ return false
66
+ }
67
+
68
+ const returned = fn => {
69
+ if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) return undefined
70
+ if (!ts.isBlock(fn.body)) return fn.body
71
+ const r = fn.body.statements.find(ts.isReturnStatement)
72
+ return r?.expression
73
+ }
74
+ /** The first handle a function's body reads: the source `state.linked` follows. */
75
+ function handleIn(node) {
76
+ /** @type {string | undefined} */
77
+ let found
78
+ const walk = n => {
79
+ if (found) return
80
+ if (ts.isPropertyAccessExpression(n) && n.name.text === 'value' && isHandle(n.expression)) found = n.expression.getText(sf)
81
+ else ts.forEachChild(n, walk)
82
+ }
83
+ walk(node)
84
+ return found
85
+ }
86
+ const flag = (node, why) => report('S001', node.getStart(sf), node.end, why)
87
+
88
+ function visit(node) {
89
+ if (ts.isPropertyAssignment(node) && ts.isIdentifier(node.name) && ESCAPES.has(node.name.text)) return
90
+ if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name) && ESCAPES.has(node.name.text)) return
91
+ const fn = ts.isFunctionLike(node)
92
+ if (fn) frames.push({ handles: new Set(), values: new Set() })
93
+ try {
94
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
95
+ const init = peel(node.initializer)
96
+ if (isQueryCall(init)) frames.at(-1).handles.add(node.name.text)
97
+ else if (serverValue(init)) frames.at(-1).values.add(node.name.text)
98
+ }
99
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(node.left) && node.left.name.text === 'value' && serverValue(node.right))
100
+ flag(node, `A server value is copied into \`${node.left.expression.getText(sf)}\`: the copy goes stale, and its rows lose their identity.`)
101
+ if (ts.isCallExpression(node)) {
102
+ const c = node.expression
103
+ if (ts.isIdentifier(c) && ESCAPES.has(c.text)) return
104
+ if (ts.isPropertyAccessExpression(c) && ts.isIdentifier(c.expression) && c.expression.text === 'state' && c.name.text === 'linked') {
105
+ const source = handleIn(node.arguments[0] ?? node)
106
+ if (source && node.arguments.length < 2) {
107
+ const at = node.arguments[0].end
108
+ report('S012', node.getStart(sf), node.end, `\`state.linked\` over \`${source}\` has no \`key\`: a refetch resets what someone is typing.`,
109
+ { description: `Key it by the row: \`{ key: () => ${source}.value?.id }\``, safe: false, edits: [[at, at, `, { key: () => ${source}.value?.id }`]] })
110
+ }
111
+ return // the declared editing path: not S001
112
+ }
113
+ if (ts.isIdentifier(c) && c.text === 'state' && node.arguments[0] && serverValue(node.arguments[0]))
114
+ flag(node, 'A server value is copied into `state`: the copy goes stale, and its rows lose their identity.')
115
+ if (ts.isPropertyAccessExpression(c) && c.name.text === 'set' && node.arguments.length === 1) {
116
+ const r = returned(node.arguments[0])
117
+ if (r && serverValue(r)) flag(node, `A server value is copied into \`${c.expression.getText(sf)}\`: the copy goes stale, and its rows lose their identity.`)
118
+ }
119
+ // The value a callback gets from a query: `watch(() => q.value, v => …)`, `q.fetch().then(v => …)`.
120
+ const fed = ts.isIdentifier(c) && c.text === 'watch' && returned(node.arguments[0]) && serverValue(returned(node.arguments[0])) ? node.arguments[1]
121
+ : ts.isPropertyAccessExpression(c) && c.name.text === 'then' && isFetch(peel(c.expression)) ? node.arguments[0] : undefined
122
+ if (fed && (ts.isArrowFunction(fed) || ts.isFunctionExpression(fed)) && ts.isIdentifier(fed.parameters[0]?.name)) {
123
+ frames.push({ handles: new Set(), values: new Set([fed.parameters[0].name.text]) })
124
+ ts.forEachChild(node, visit)
125
+ frames.pop()
126
+ return
127
+ }
128
+ }
129
+ ts.forEachChild(node, visit)
130
+ } finally {
131
+ if (fn) frames.pop()
132
+ }
133
+ }
134
+ visit(sf)
135
+ }
136
+
137
+ /** The local names `resource` is imported as from @weft-js/core. */
138
+ const resourceNames = sf => new Set(sf.statements.flatMap(n => ts.isImportDeclaration(n) && n.moduleSpecifier.text === '@weft-js/core' && n.importClause?.namedBindings && ts.isNamedImports(n.importClause.namedBindings)
139
+ ? n.importClause.namedBindings.elements.filter(s => (s.propertyName ?? s.name).text === 'resource').map(s => s.name.text) : []))
140
+
141
+ /**
142
+ * X037: a `resource` or query made in a component's own body and read inside a `<Boundary>` that component renders. A
143
+ * Boundary waits for what is made while its children are built, so it never sees this one: no fallback shows, and the
144
+ * content renders with nothing in it. Only a read inside the Boundary's children counts: reading it elsewhere is fine.
145
+ * @param {ts.SourceFile} sf @param {Set<string>} makers names whose call makes one (`resource` and the queries)
146
+ */
147
+ function madeBesideBoundary(sf, makers, report) {
148
+ const peel = e => { while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e)) e = e.expression; return e }
149
+ const maker = e => {
150
+ e = peel(e)
151
+ if (!ts.isCallExpression(e)) return undefined
152
+ const c = e.expression
153
+ const name = ts.isIdentifier(c) ? c.text : ts.isPropertyAccessExpression(c) && c.name.text === 'live' && ts.isIdentifier(c.expression) ? c.expression.text : undefined
154
+ return name !== undefined && makers.has(name) ? e : undefined
155
+ }
156
+ const nested = n => ts.isFunctionDeclaration(n) || (ts.isVariableDeclaration(n) && !!n.initializer && (ts.isArrowFunction(n.initializer) || ts.isFunctionExpression(n.initializer)))
157
+ function component(fn) {
158
+ /** @type {Map<string, ts.CallExpression>} */
159
+ const made = new Map()
160
+ for (const st of fn.body.statements) {
161
+ if (!ts.isVariableStatement(st)) continue
162
+ for (const d of st.declarationList.declarations) {
163
+ const call = ts.isIdentifier(d.name) && d.initializer ? maker(d.initializer) : undefined
164
+ if (call) made.set(d.name.text, call)
165
+ }
166
+ }
167
+ if (!made.size) return
168
+ const done = new Set()
169
+ const read = n => {
170
+ if (ts.isIdentifier(n) && made.has(n.text) && !done.has(n.text) && !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n)) {
171
+ done.add(n.text)
172
+ const call = made.get(n.text)
173
+ report('X037', call.getStart(sf), call.end, `\`${n.text}\` is made here, beside the <Boundary> that shows it, so that Boundary never waits for it: no fallback shows, and the content renders before the data. Make it in a component rendered inside the Boundary.`)
174
+ }
175
+ ts.forEachChild(n, read)
176
+ }
177
+ const find = n => {
178
+ if (nested(n)) return // another component, with its own body
179
+ if (ts.isJsxElement(n) && n.openingElement.tagName.getText(sf) === 'Boundary') n.children.forEach(read)
180
+ else ts.forEachChild(n, find)
181
+ }
182
+ fn.body.statements.forEach(find)
183
+ }
184
+ const visit = n => {
185
+ if ((ts.isFunctionDeclaration(n) || ts.isArrowFunction(n) || ts.isFunctionExpression(n)) && n.body && ts.isBlock(n.body)) component(n)
186
+ ts.forEachChild(n, visit)
187
+ }
188
+ visit(sf)
189
+ }
190
+
191
+ /** @param {string} root @param {{ files: { rel: string, text: string }[], exclude?: string[] }} options */
192
+ export function stateChecks(root, { files, exclude = [] }) {
193
+ const out = []
194
+ const cache = new Map()
195
+ const checked = new Set(files.map(f => f.rel))
196
+ // Scopes usually live in plain .ts files, which the compiler pass doesn't read.
197
+ const extra = discover(root, { include: ['src/**/*.ts'], exclude }).filter(f => !checked.has(f) && !isServerModule(f) && !/\.(d|test|spec)\.ts$/.test(f))
198
+ const all = [...files.map(f => [f.rel, f.text]), ...extra.map(f => [f, readFileSync(join(root, f), 'utf8')])]
199
+ for (const [rel, text] of all) {
200
+ const copies = /\b(state|watch|set)\b/.test(text), boundary = /\.[jt]sx$/.test(rel) && text.includes('<Boundary')
201
+ if (isServerModule(rel) || !(copies || boundary)) continue
202
+ const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, boundary, /\.[jt]sx$/.test(rel) ? ts.ScriptKind.TSX : ts.ScriptKind.TS)
203
+ const queries = importedQueries(root, rel, sf, cache)
204
+ const pos = positions(text)
205
+ const report = (code, start, end, why, fix) => {
206
+ const offset = [start, end]
207
+ out.push(diagnostic({ code, file: rel, span: pos.span(start, end), offset, why, fix: fix && { description: fix.description, safe: fix.safe, edits: fix.edits.map(([a, b, t]) => ({ file: rel, span: pos.span(a, b), offset: [a, b], text: t })) } }))
208
+ }
209
+ if (copies && queries.size) scan(rel, text, queries, report)
210
+ if (boundary) madeBesideBoundary(sf, new Set([...queries, ...resourceNames(sf)]), report)
211
+ }
212
+ return out
213
+ }