@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
@@ -0,0 +1,525 @@
1
+ // Prop inference (research 44). Facts come from syntax; the types come from whichever checker the host wraps, asked
2
+ // about the call sites in the program's own terms. A round writes each component's annotation into its file's shadow;
3
+ // the next round sees the types that made possible (a prop forwarded to another inferred component, a component passed
4
+ // as a prop). Rounds stop when nothing changes, and a project with nothing to infer never starts a checker.
5
+
6
+ const { fileFacts, hashOf, MARK } = require('./facts.cjs')
7
+ const { inGenerated, shadow, toShadow, toSource } = require('./shadow.cjs')
8
+
9
+ const ROUNDS = 5
10
+ const MAX_LITERALS = 8
11
+
12
+ const quote = key => (/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key))
13
+ const unique = list => [...new Set(list)]
14
+ const union = list => {
15
+ const parts = unique(list.filter(Boolean))
16
+ return parts.length ? parts.map(t => (/=>/.test(t) && !/^\(.*\)$/.test(t) ? `(${t})` : t)).join(' | ') : null
17
+ }
18
+ const sameFile = (a, b) => a === b || a.toLowerCase() === b.toLowerCase()
19
+ const overlaps = (a, b) => a[0] < b[1] && b[0] < a[1]
20
+
21
+ class Engine {
22
+ /** @param {{ ts: any, read: (file: string) => string | undefined, routes?: (file: string) => boolean }} options */
23
+ constructor({ ts, read, routes }) {
24
+ this.ts = ts
25
+ this.read = read
26
+ this.routes = routes ?? (f => /\/src\/routes\//.test(f))
27
+ this.facts = new Map()
28
+ this.texts = new Map()
29
+ this.maps = new Map() // file → shadow, for files that differ from their source
30
+ this.state = new Map() // comp id → { type, keys, sites, ... }
31
+ this.findings = new Map() // file → engine diagnostics
32
+ this.typed = new Map() // file → what the checker said about its sites, until the file or a shadow it reads moves
33
+ this.flowCache = new Map()
34
+ this.diags = new Map() // file → the checker's errors for one shadow text
35
+ this.touched = new Set() // files whose errors the last run may have moved
36
+ this.stats = { rounds: 0, queries: 0, parses: 0 }
37
+ }
38
+
39
+ /** New text for a file. @returns whether anything about it changed */
40
+ set(file, text = this.read(file)) {
41
+ if (text === undefined) { const had = this.facts.delete(file); this.texts.delete(file); this.maps.delete(file); this.typed.delete(file); this.flowCache.delete(file); return had }
42
+ const before = this.facts.get(file)
43
+ this.texts.set(file, text)
44
+ const hash = hashOf(text)
45
+ if (before?.hash === hash) return false
46
+ this.stats.parses++
47
+ this.facts.set(file, fileFacts(this.ts, file, text))
48
+ return true
49
+ }
50
+
51
+ comps() { return [...this.facts.values()].flatMap(f => f.comps) }
52
+ /** Facts that need a shadow at all: most projects have none, and then no checker starts. */
53
+ needed() { return [...this.facts.values()].some(f => f.comps.length || f.sites.some(s => s.attrs.some(a => a.bind))) }
54
+
55
+ /** Files with a component or a site of one that import `file`: what they pass may have changed type. */
56
+ dependents(file) {
57
+ const base = file.replace(/^.*[\\/]/, '').replace(/\.[^.]*$/, '')
58
+ const names = new Set(this.comps().map(c => c.name))
59
+ const out = new Set()
60
+ for (const f of this.facts.values()) {
61
+ if (!f.comps.length && !f.sites.some(s => names.has(s.tag))) continue
62
+ for (const { from } of f.imports.values()) if (from === base || from.endsWith(`/${base}`)) { out.add(f.file); break }
63
+ }
64
+ return out
65
+ }
66
+
67
+ /** Whether a saved file can move anything: it holds a component or a site of one, or such a file imports it. */
68
+ relevant(file) {
69
+ if (!this.needed()) return false
70
+ const facts = this.facts.get(file)
71
+ if (!facts || facts.comps.length || facts.sites.some(s => s.attrs.some(a => a.bind))) return true
72
+ const names = new Set(this.comps().map(c => c.name))
73
+ return facts.sites.some(s => names.has(s.tag) || (this.flowing?.has(file) ?? false)) || this.dependents(file).size > 0
74
+ }
75
+
76
+ /** `comp` for a tag that needs no checker: a name in the same file, or a local wrap of one. */
77
+ direct(facts, site) {
78
+ const names = new Set(), todo = [site.tag.split('.')[0]]
79
+ while (todo.length) {
80
+ const n = todo.pop()
81
+ if (names.has(n)) continue
82
+ names.add(n)
83
+ todo.push(...(facts.wraps.get(n) ?? []))
84
+ }
85
+ return facts.comps.filter(c => names.has(c.name))
86
+ }
87
+
88
+ shadowOf(file) { return this.maps.get(file) }
89
+
90
+ edits(facts) {
91
+ const edits = []
92
+ for (const site of facts.sites) for (const a of site.attrs) {
93
+ if (!a.bind) continue
94
+ if (a.raw) edits.push({ start: a.raw[0], end: a.raw[0] + 5, text: '' }, { start: a.span[1], end: a.span[1], text: '.value' })
95
+ }
96
+ for (const c of facts.comps) {
97
+ const type = this.state.get(c.id)?.type ?? 'any'
98
+ const note = `: ${type}`
99
+ if (c.written) edits.push({ start: c.written[0], end: c.written[1], text: note })
100
+ else if (c.parens) edits.push({ start: c.nameEnd, end: c.nameEnd, text: note })
101
+ else edits.push({ start: c.paramSpan[0], end: c.paramSpan[0], text: '(' }, { start: c.nameEnd, end: c.nameEnd, text: `${note})` })
102
+ }
103
+ return edits
104
+ }
105
+
106
+ /** Rewrite the files whose shadow moved, and tell the host. */
107
+ publish(host, only) {
108
+ const changed = new Map()
109
+ for (const [file, facts] of this.facts) {
110
+ if (only && !only.has(file)) continue
111
+ if (!facts.comps.length && !facts.sites.some(s => s.attrs.some(a => a.bind))) {
112
+ if (this.maps.delete(file)) changed.set(file, this.texts.get(file))
113
+ continue
114
+ }
115
+ const map = shadow(this.texts.get(file), this.edits(facts))
116
+ if (this.maps.get(file)?.text !== map.text) { this.maps.set(file, map); changed.set(file, map.text) }
117
+ }
118
+ if (changed.size) host.update(changed)
119
+ return changed
120
+ }
121
+
122
+ /** The checker's errors, once per shadow text: conflicts, naming and reporting all want them. */
123
+ diag(host, file) {
124
+ const text = this.maps.get(file)?.text ?? this.texts.get(file)
125
+ const hit = this.diags.get(file)
126
+ if (hit?.text === text) return hit.list
127
+ const list = host.diagnostics(file)
128
+ this.diags.set(file, { text, list })
129
+ return list
130
+ }
131
+
132
+ ask(host, file, reqs) {
133
+ if (!reqs.length) return []
134
+ this.stats.queries += reqs.length
135
+ const map = this.maps.get(file)
136
+ const at = p => (map ? toShadow(map, p) : p)
137
+ return host.query(file, reqs.map(r => ({ kind: r.kind, start: at(r.span[0]), end: at(r.span[1]) })))
138
+ }
139
+
140
+ /** Who a tag renders when its name doesn't say: a component held in a variable, a map, a prop, an import. */
141
+ flows(host, force) {
142
+ const out = new Map() // "file:start" → comps
143
+ const comps = this.comps()
144
+ const open = comps.filter(c => c.exported || this.facts.get(c.file).escaped.has(c.name) || [...this.facts.values()].some(f => [...f.wraps.values()].some(l => l.includes(c.name))))
145
+ if (!open.length) return out
146
+ const names = new Set(open.map(c => c.name))
147
+ for (const [file, facts] of this.facts) {
148
+ const cross = s => {
149
+ const head = s.tag.split('.')[0]
150
+ const imported = facts.imports.get(head)
151
+ if (imported) return names.has(imported.name) || imported.name === 'default' || names.has(head)
152
+ return !facts.comps.some(c => c.name === s.tag)
153
+ }
154
+ const held = this.flowCache.get(file)
155
+ if (force && !force.has(file) && held?.facts === facts) {
156
+ for (const [k, v] of held.hits) out.set(k, v)
157
+ continue
158
+ }
159
+ const sites = facts.sites.filter(s => !this.direct(facts, s).length && cross(s))
160
+ const hits = new Map()
161
+ this.flowCache.set(file, { facts, hits })
162
+ if (!sites.length) continue
163
+ const res = this.ask(host, file, sites.map(s => ({ kind: 'decls', span: s.tagSpan })))
164
+ sites.forEach((s, i) => {
165
+ const found = []
166
+ for (const d of res[i].decls ?? []) {
167
+ for (const c of open) {
168
+ if (!sameFile(c.file, d.file)) continue
169
+ const map = this.maps.get(c.file)
170
+ if ((map ? toShadow(map, c.bodySpan[1]) : c.bodySpan[1]) === d.end && !found.includes(c)) found.push(c)
171
+ }
172
+ }
173
+ if (found.length) { hits.set(`${file}:${s.span[0]}`, found); out.set(`${file}:${s.span[0]}`, found) }
174
+ })
175
+ }
176
+ return out
177
+ }
178
+
179
+ /** Every call site of every component, with what the checker says each attribute is. */
180
+ collect(host, force) {
181
+ const flows = this.flows(host, force)
182
+ const sites = new Map(this.comps().map(c => [c.id, []]))
183
+ for (const [file, facts] of this.facts) {
184
+ const targets = facts.sites.map(s => { const d = this.direct(facts, s); return d.length ? d : flows.get(`${file}:${s.span[0]}`) ?? [] })
185
+ let held = this.typed.get(file)
186
+ if (!held || held.facts !== facts || !force || force.has(file)) {
187
+ const reqs = [], back = []
188
+ facts.sites.forEach((s, i) => {
189
+ if (!targets[i].length) return
190
+ for (const a of s.attrs) if (a.span) { back.push(a); reqs.push({ kind: 'type', span: a.span }) }
191
+ for (const sp of s.spreads) { back.push(sp); reqs.push({ kind: 'spread', span: sp.span }) }
192
+ if (s.children?.span) { back.push(s.children); reqs.push({ kind: 'type', span: s.children.span }) }
193
+ })
194
+ const res = this.ask(host, file, reqs)
195
+ held = { facts, typed: new Map(back.map((a, i) => [a, res[i]])) }
196
+ this.typed.set(file, held)
197
+ }
198
+ const { typed } = held
199
+ facts.sites.forEach((s, i) => { for (const c of targets[i]) sites.get(c.id).push({ file, site: s, typed }) })
200
+ }
201
+ return sites
202
+ }
203
+
204
+ /** What the body alone says about `key`: what's left when no caller says, or a caller says `any`. */
205
+ fromBody(c, key, entry, cache) {
206
+ const q = kind => span => cache(kind, span)
207
+ const typeOf = q('type'), ctx = q('context')
208
+ let member = null
209
+ for (const u of entry.uses) {
210
+ if (u.as === 'number') return 'number'
211
+ if (u.as === 'call') return `(${u.args.map((a, i) => `arg${i}: ${typeOf(a).text}`).join(', ')}) => any`
212
+ if (u.as === 'context') { const t = ctx(u.span); if (t && !t.any && t.text !== 'unknown') return t.text.replace(/ \| undefined$/, '') }
213
+ if (u.as === 'write') { const t = typeOf(u.value); if (!t.any) return t.text }
214
+ if (u.as === 'member') member = u.member
215
+ }
216
+ if (member) return /^(length|map|filter|forEach|slice|find|some|every|join|reduce)$/.test(member) ? 'any[]' : `{ ${member}: any }`
217
+ if (entry.fallback) { const t = typeOf(entry.fallback); if (!t.any) return t.text }
218
+ return null
219
+ }
220
+
221
+ infer(c, sites, host) {
222
+ const facts = this.facts.get(c.file)
223
+ const prev = this.state.get(c.id) ?? {}
224
+ const st = { keys: new Map(), sites: sites.length, pins: prev.pins ?? new Map(), shape: prev.shape, anyFrom: [], noCallers: false }
225
+ if (this.routes(c.file) && c.isDefault) {
226
+ st.type = `import('@weft-js/start').RouteProps${facts.exportsLoad ? '<typeof load>' : ''}`
227
+ st.route = true
228
+ return st
229
+ }
230
+ const outer = sites.filter(s => !(s.site.in === c.name && s.file === c.file))
231
+ if (!outer.length) st.noCallers = true
232
+
233
+ const memo = new Map()
234
+ const cache = (kind, span) => {
235
+ const k = `${kind}:${span}`
236
+ if (!memo.has(k)) memo.set(k, this.ask(host, c.file, [{ kind, span }])[0])
237
+ return memo.get(k)
238
+ }
239
+
240
+ const seen = new Map() // key → [{ text, exact, any, untypedFn, at }]
241
+ const add = (key, v) => { if (!seen.has(key)) seen.set(key, []); seen.get(key).push(v) }
242
+ for (const { file, site, typed } of sites) {
243
+ const self = site.in === c.name && file === c.file
244
+ for (const a of site.attrs) {
245
+ const r = a.span ? typed.get(a) : null
246
+ const held = a.value && this.facts.get(file).comps.some(x => x.name === a.value)
247
+ const v = held ? { text: `typeof ${a.value}` } : a.literal === 'true' ? { text: 'boolean' } : a.literal === 'string' ? { text: 'string', exact: a.exact } : r ?? { text: 'any', any: true }
248
+ if (self) continue // a component rendering itself says nothing new about its props
249
+ add(a.name, { ...v, untypedFn: a.untypedFn, at: { file, span: a.span ?? a.whole ?? site.tagSpan, whole: a.whole ?? site.tagSpan, tag: site.tag, tagAt: site.tagSpan[0] }, self, literal: a.literal })
250
+ }
251
+ for (const sp of site.spreads) {
252
+ const r = typed.get(sp)
253
+ if (!self) for (const p of r?.props ?? []) add(p.name, { text: p.text, any: p.any, at: { file, span: sp.span, tag: site.tag }, self, optional: p.optional })
254
+ }
255
+ if (site.children && !self) {
256
+ const r = site.children.span ? typed.get(site.children) : null
257
+ add('children', { ...(r ?? { text: 'unknown' }), untypedFn: site.children.untypedFn, at: { file, span: site.tagSpan, tag: site.tag }, self })
258
+ }
259
+ }
260
+
261
+ const fwd = c.forwards
262
+ const named = new Set(c.info.keys.keys())
263
+ const all = fwd.length ? named : new Set([...seen.keys(), ...named])
264
+ const parts = []
265
+ for (const key of all) {
266
+ const entry = c.info.keys.get(key) ?? { uses: [] }
267
+ const given = seen.get(key) ?? []
268
+ const real = given.filter(v => !v.any)
269
+ const body = (given.every(v => v.any || v.untypedFn) || !given.length) ? this.fromBody(c, key, entry, cache) : null
270
+ let text
271
+ const lits = real.filter(v => v.exact?.startsWith('"'))
272
+ if (real.length && lits.length === real.length && unique(lits.map(v => v.exact)).length > 1 && unique(lits.map(v => v.exact)).length <= MAX_LITERALS) text = union(lits.map(v => v.exact))
273
+ else text = body ?? union(real.map(v => v.text))
274
+ const distinct = unique(real.map(v => v.text))
275
+ const tally = new Map(distinct.map(t => [t, real.filter(v => v.text === t).length]))
276
+ const number = entry.uses.some(u => u.as === 'number') && !tally.has('number') && distinct.length ? 'number' : null
277
+ if (number) tally.set(number, 0)
278
+ if (st.pins.has(key)) text = st.pins.get(key)
279
+ else if (number) text = union([text, number])
280
+ if (given.some(v => v.any) && !real.length) st.anyFrom.push(...given.filter(v => v.any).map(v => ({ key, ...v.at, body: !!body })))
281
+ if (!text) {
282
+ if (given.length) text = 'any'
283
+ else if (entry.fallback) text = 'any'
284
+ else if (outer.length) text = 'undefined'
285
+ else text = 'any'
286
+ }
287
+ const passed = outer.filter(s => s.site.attrs.some(a => a.name === key) || (key === 'children' && s.site.children) || s.site.spreads.some(sp => s.typed.get(sp)?.props?.some(p => p.name === key && !p.optional)))
288
+ const opaque = outer.some(s => s.site.spreads.some(sp => s.typed.get(sp)?.any))
289
+ const required = outer.length && passed.length === outer.length && !entry.fallback && !opaque && text !== 'undefined'
290
+ const onlySelf = !outer.length
291
+ const optional = onlySelf ? !!entry.fallback : !required
292
+ st.keys.set(key, { text, optional, given, tally, uses: entry.uses })
293
+ parts.push(`${quote(key)}${optional ? '?' : ''}: ${text}`)
294
+ }
295
+ const own = parts.length ? `{ ${parts.join('; ')} }` : '{}'
296
+ if (fwd.length) {
297
+ const f = fwd[0]
298
+ const base = `Parameters<typeof ${f.tag}>[0]`
299
+ const names = unique([...named, ...f.given])
300
+ const taken = names.map(n => JSON.stringify(n)).join(' | ')
301
+ const left = names.length ? `Omit<${base}, ${taken}>` : base
302
+ const loose = f.given.filter(n => !named.has(n))
303
+ const given = loose.length ? ` & Partial<Pick<${base}, Extract<keyof ${base}, ${loose.map(n => JSON.stringify(n)).join(' | ')}>>>` : ''
304
+ st.type = parts.length ? `${left}${given} & ${own}` : `${left}${given}`
305
+ } else st.type = c.info.opaque ? 'any' : st.shape ?? own
306
+ return st
307
+ }
308
+
309
+ /** The body can't take the union: try each caller's type alone, and leave the rest as errors at those callers. */
310
+ conflicts(host, comps) {
311
+ for (const c of comps) {
312
+ const st = this.state.get(c.id)
313
+ if (!st?.keys) continue
314
+ for (const [key, k] of st.keys) {
315
+ if (k.tally.size < 2 || st.pins.has(key)) continue
316
+ const map = this.maps.get(c.file)
317
+ const uses = k.uses.map(u => u.span.map(p => toShadow(map, p)))
318
+ const hurt = () => this.diag(host, c.file).filter(d => uses.some(u => overlaps(u, [d.start, d.start + d.length])))
319
+ if (!hurt().length) continue
320
+ const shape = this.shapes(st)
321
+ if (shape) {
322
+ st.shape = shape
323
+ this.state.set(c.id, this.infer(c, this.lastSites.get(c.id), host))
324
+ this.publish(host, new Set([c.file]))
325
+ const m2 = this.maps.get(c.file)
326
+ const u2 = k.uses.map(u => u.span.map(p => toShadow(m2, p)))
327
+ if (!this.diag(host, c.file).some(d => u2.some(u => overlaps(u, [d.start, d.start + d.length])))) { st.shape = shape; break }
328
+ st.shape = undefined
329
+ this.state.set(c.id, this.infer(c, this.lastSites.get(c.id), host))
330
+ this.publish(host, new Set([c.file]))
331
+ }
332
+ const order = [...k.tally].sort((a, b) => b[1] - a[1]).map(([t]) => t)
333
+ let chosen = null
334
+ for (const t of order) {
335
+ st.pins.set(key, t)
336
+ this.state.set(c.id, this.infer(c, this.lastSites.get(c.id), host))
337
+ this.publish(host, new Set([c.file]))
338
+ const m2 = this.maps.get(c.file)
339
+ const u2 = k.uses.map(u => u.span.map(p => toShadow(m2, p)))
340
+ if (!this.diag(host, c.file).some(d => u2.some(u => overlaps(u, [d.start, d.start + d.length])))) { chosen = t; break }
341
+ }
342
+ if (chosen) {
343
+ st.pins.set(key, chosen)
344
+ const next = this.infer(c, this.lastSites.get(c.id), host)
345
+ this.state.set(c.id, next)
346
+ this.publish(host, new Set([c.file]))
347
+ next.odd = [...(next.odd ?? []), ...k.given.filter(v => !v.any && v.text !== chosen).map(v => ({ key, type: v.text, want: chosen, ...v.at, use: k.uses.find(u => u.as !== 'read') ?? k.uses[0] }))]
348
+ } else {
349
+ st.pins.delete(key)
350
+ this.state.set(c.id, this.infer(c, this.lastSites.get(c.id), host))
351
+ this.publish(host, new Set([c.file]))
352
+ }
353
+ }
354
+ }
355
+ }
356
+
357
+ /** Callers that differ by a string prop: one object type per value, so the body can narrow on it. */
358
+ shapes(st) {
359
+ const keys = [...st.keys]
360
+ const tag = keys.find(([, k]) => { const lits = k.given.map(v => v.exact); return lits.length && lits.every(l => l?.startsWith('"')) && new Set(lits).size > 1 })
361
+ if (!tag) return null
362
+ const groups = new Map()
363
+ for (const v of tag[1].given) {
364
+ if (!groups.has(v.exact)) groups.set(v.exact, new Set())
365
+ groups.get(v.exact).add(`${v.at.file}:${v.at.whole?.[0]}`)
366
+ }
367
+ return [...groups].map(([lit]) => {
368
+ const parts = [`${quote(tag[0])}: ${lit}`]
369
+ for (const [key, k] of keys) {
370
+ if (key === tag[0]) continue
371
+ const near = tag[1].given.filter(v => v.exact === lit).map(v => v.at)
372
+ const mine = k.given.filter(v => near.some(n => n.tagAt === v.at.tagAt && n.file === v.at.file))
373
+ if (mine.length) parts.push(`${quote(key)}${mine.length < near.length ? '?' : ''}: ${union(mine.map(v => v.text))}`)
374
+ }
375
+ return `{ ${parts.join('; ')} }`
376
+ }).join(' | ')
377
+ }
378
+
379
+ /** A type the checker printed in another scope may not be nameable here: then this component is `any`, and says so. */
380
+ unprintable(host, comps) {
381
+ for (const c of comps) {
382
+ const map = this.maps.get(c.file)
383
+ if (!map || !this.state.get(c.id)?.keys) continue
384
+ for (const d of this.diag(host, c.file)) {
385
+ const at = toSource(map, d.start)
386
+ if (!inGenerated(map, d.start, d.length) || at !== c.nameEnd) continue
387
+ const st = this.state.get(c.id)
388
+ st.unnamed = d.text
389
+ st.type = 'any'
390
+ }
391
+ }
392
+ }
393
+
394
+ /** The checker's errors in a file, in source positions, without what the engine already said better. */
395
+ errors(host, file) {
396
+ const map = this.maps.get(file)
397
+ const said = (this.findings.get(file) ?? []).filter(f => f.code === 'W084')
398
+ return this.diag(host, file).flatMap(d => {
399
+ if (map && inGenerated(map, d.start, d.length)) return []
400
+ const start = map ? toSource(map, d.start) : d.start
401
+ const end = map ? toSource(map, d.start + d.length) : d.start + d.length
402
+ if (said.some(f => overlaps([f.start, f.end], [start, Math.max(end, start + 1)]))) return []
403
+ return [{ code: `TS${d.code}`, start, end, why: d.text, help: 'Fix the type error' }]
404
+ })
405
+ }
406
+
407
+ /** Files whose types depend on inference: where a component is, and where it's rendered. */
408
+ affected() {
409
+ const files = new Set(this.maps.keys())
410
+ for (const sites of this.lastSites?.values() ?? []) for (const s of sites) files.add(s.file)
411
+ return files
412
+ }
413
+
414
+ /** @returns {Map<string, {code: string, severity?: string, start: number, end: number, why: string, help?: string, edit?: object}[]>} */
415
+ run(host, changed) {
416
+ this.findings = new Map()
417
+ const comps = this.comps()
418
+ if (!this.needed()) { this.publish(host); return this.findings }
419
+ const first = this.publish(host)
420
+ let force = changed ? new Set(changed) : undefined
421
+ if (force) {
422
+ for (const f of changed) for (const d of this.dependents(f)) force.add(d)
423
+ for (const f of first.keys()) force.add(f)
424
+ }
425
+ const touched = new Set(force)
426
+ const sitesOf = id => (this.lastSites?.get(id) ?? []).map(s => s.file)
427
+ let dirty = comps
428
+ for (let round = 0; round < ROUNDS; round++) {
429
+ this.stats.rounds++
430
+ const sites = this.collect(host, force)
431
+ this.lastSites = sites
432
+ if (force) dirty = comps.filter(c => !this.state.has(c.id) || force.has(c.file) || sites.get(c.id).some(s => force.has(s.file)))
433
+ const moved = []
434
+ for (const c of dirty) {
435
+ const st = this.infer(c, sites.get(c.id), host)
436
+ if (st.type !== this.state.get(c.id)?.type) moved.push(c)
437
+ this.state.set(c.id, st)
438
+ }
439
+ if (!moved.length) break
440
+ const out = this.publish(host)
441
+ force = new Set([...out.keys(), ...moved.flatMap(c => [c.file, ...sitesOf(c.id)])])
442
+ for (const f of force) touched.add(f)
443
+ }
444
+ this.conflicts(host, dirty)
445
+ this.unprintable(host, dirty)
446
+ this.publish(host)
447
+ for (const c of comps) this.report(c)
448
+ this.touched = touched
449
+ return this.findings
450
+ }
451
+
452
+ note(file, finding) {
453
+ if (!this.findings.has(file)) this.findings.set(file, [])
454
+ this.findings.get(file).push(finding)
455
+ }
456
+
457
+ /** The source edits that write a type down. */
458
+ annotation(c, type) {
459
+ const text = `: ${MARK} ${type}`
460
+ if (c.written) return [{ start: c.written[0], end: c.written[1], text }]
461
+ if (c.parens) return [{ start: c.nameEnd, end: c.nameEnd, text }]
462
+ return [{ start: c.paramSpan[0], end: c.paramSpan[0], text: '(' }, { start: c.nameEnd, end: c.nameEnd, text: `${text})` }]
463
+ }
464
+
465
+ report(c) {
466
+ const st = this.state.get(c.id)
467
+ if (!st || st.route) return
468
+ const [start, end] = c.nameSpan
469
+ const line = (file, pos) => this.texts.get(file).slice(0, pos).split('\n').length
470
+ if (st.unnamed) this.note(c.file, { code: 'W085', severity: 'warning', start, end, why: `\`${c.name}\`'s inferred props name a type this scope can't see, so they fell back to \`any\`: ${st.unnamed}`, help: 'Move the type to module scope or export it, or annotate the parameter' })
471
+ const exposed = c.exported
472
+ if (st.noCallers && !exposed) {
473
+ const escaped = this.facts.get(c.file).escaped.has(c.name)
474
+ this.note(c.file, escaped
475
+ ? { code: 'W083', severity: 'warning', start, end, why: `\`${c.name}\` is used as a value and no JSX site for it could be traced, so its props are \`any\``, help: `Annotate its parameter: function ${c.name}(props: { ... })` }
476
+ : { code: 'W080', start, end, why: `\`${c.name}\` has an untyped parameter and nothing renders it, so its props have nothing to be inferred from`, help: `Render it (\`<${c.name} ... />\`), delete it, or annotate the parameter: function ${c.name}(props: { ... })` })
477
+ }
478
+ if (exposed) {
479
+ const type = st.type
480
+ const edits = this.annotation(c, type)
481
+ const bad = st.noCallers
482
+ if (!c.written) this.note(c.file, { code: 'W081', start, end, why: `\`${c.name}\` is exported with no prop type${bad ? ' and no caller to infer one from' : ''}. An export's type is written down, so a caller's edit can't change a public API unseen`, help: `\`weft fix\` writes the inferred type: ${c.name}(props: ${type})`, edits: bad ? undefined : edits, safe: !bad })
483
+ else if (c.writtenType.replace(MARK, '').trim() !== type && !bad) this.note(c.file, { code: 'W082', start, end, why: `\`${c.name}\`'s written prop type no longer matches its callers: it says ${c.writtenType.replace(MARK, '').trim()}, they need ${type}`, help: '`weft fix` rewrites it (a diff to review), or delete the /*inferred*/ comment to keep the type you have', edits, safe: true })
484
+ }
485
+ for (const a of st.anyFrom) {
486
+ const origin = this.origin(a.file, a.span)
487
+ this.note(c.file, { code: 'W083', severity: 'warning', start, end, why: `\`${c.name}\`'s prop \`${a.key}\` is ${a.body ? 'typed from its use in the body, because' : '`any`, because'} the value at ${a.tag} (line ${line(a.file, a.span[0])}) is \`any\`${origin ? `: it comes from ${origin}` : ''}`, help: 'Give that source a type, or annotate the prop' })
488
+ }
489
+ for (const o of st.odd ?? []) {
490
+ const use = o.use?.span ? this.texts.get(c.file).slice(...o.use.span) : o.key
491
+ this.note(o.file, { code: 'W084', start: o.whole[0], end: o.whole[1], why: `\`${o.key}\` here is ${o.type}, but \`${c.name}\` uses it as ${o.want} (\`${use}\`, line ${line(c.file, o.use?.span?.[0] ?? c.nameSpan[0])}), and its other callers agree`, help: `Pass ${o.want} here, or change \`${c.name}\` to handle ${o.type} (a type parameter, if the two props must agree)` })
492
+ }
493
+ }
494
+
495
+ /** Where an `any` started, by following names in the file: `JSON.parse(raw)`, an untyped parameter, an import. */
496
+ origin(file, span) {
497
+ const ts = this.ts
498
+ const text = this.texts.get(file)
499
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
500
+ const lineOf = n => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1
501
+ let node = null
502
+ const find = n => { if (n.getStart(sf) <= span[0] && n.end >= span[1]) { node = n; ts.forEachChild(n, find) } }
503
+ ts.forEachChild(sf, find)
504
+ for (let i = 0; node && i < 6; i++) {
505
+ if (ts.isPropertyAccessExpression(node)) node = node.expression
506
+ if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isNonNullExpression(node)) { node = node.expression; continue }
507
+ if (ts.isCallExpression(node)) return `\`${node.getText(sf).slice(0, 48)}\` (line ${lineOf(node)})`
508
+ if (!ts.isIdentifier(node)) return `\`${node.getText(sf).slice(0, 48)}\` (line ${lineOf(node)})`
509
+ let decl = null
510
+ const look = n => {
511
+ if ((ts.isVariableDeclaration(n) || ts.isParameter(n) || ts.isImportSpecifier(n) || ts.isImportClause(n)) && n.name?.getText(sf) === node.text && n.pos < node.pos) decl = n
512
+ ts.forEachChild(n, look)
513
+ }
514
+ ts.forEachChild(sf, look)
515
+ if (!decl) return `\`${node.text}\``
516
+ if (ts.isParameter(decl)) return `the parameter \`${node.text}\`, which has no type (line ${lineOf(decl)})`
517
+ if (ts.isImportSpecifier(decl) || ts.isImportClause(decl)) return `\`${node.text}\`, imported at line ${lineOf(decl)}`
518
+ if (!decl.initializer) return `\`${node.text}\` (line ${lineOf(decl)})`
519
+ node = decl.initializer
520
+ }
521
+ return null
522
+ }
523
+ }
524
+
525
+ module.exports = { Engine }