@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.
- package/LICENSE +16 -0
- package/README.md +74 -2
- package/bin/weft.mjs +8 -0
- package/docs/async.md +40 -0
- package/docs/browser.md +39 -0
- package/docs/config.md +93 -0
- package/docs/deploy.md +83 -0
- package/docs/design.md +97 -0
- package/docs/fonts.md +70 -0
- package/docs/icons.md +102 -0
- package/docs/lists.md +23 -0
- package/docs/meta.md +82 -0
- package/docs/plugins.md +143 -0
- package/docs/props.md +77 -0
- package/docs/rules.md +32 -0
- package/docs/rust.md +223 -0
- package/docs/start-short.md +8 -0
- package/docs/start.md +422 -0
- package/docs/state.md +96 -0
- package/docs/style.md +59 -0
- package/docs/surface.md +111 -0
- package/docs/testing.md +71 -0
- package/docs/ui-state.md +92 -0
- package/docs/verify.md +48 -0
- package/package.json +39 -4
- package/src/add-rust.mjs +123 -0
- package/src/add.mjs +358 -0
- package/src/agents-md.mjs +134 -0
- package/src/alias.mjs +59 -0
- package/src/audit.mjs +366 -0
- package/src/browser/cli.mjs +34 -0
- package/src/browser/client.mjs +132 -0
- package/src/browser/commands.mjs +143 -0
- package/src/catalog.mjs +496 -0
- package/src/check.mjs +330 -0
- package/src/cli.mjs +364 -0
- package/src/config.d.ts +1 -0
- package/src/config.mjs +5 -0
- package/src/contributions.mjs +69 -0
- package/src/db-layer.mjs +33 -0
- package/src/deploy/cdn.mjs +64 -0
- package/src/deploy/files.mjs +187 -0
- package/src/deploy/fit.mjs +62 -0
- package/src/deploy/hosts.mjs +56 -0
- package/src/deploy/index.mjs +273 -0
- package/src/deploy/needs.mjs +92 -0
- package/src/design-checks.mjs +439 -0
- package/src/diag.mjs +74 -0
- package/src/docs.mjs +132 -0
- package/src/doctor.mjs +30 -0
- package/src/files.mjs +47 -0
- package/src/fix.mjs +40 -0
- package/src/gate.mjs +276 -0
- package/src/index.mjs +9 -0
- package/src/infer/engine.cjs +525 -0
- package/src/infer/facts.cjs +252 -0
- package/src/infer/hosts.cjs +114 -0
- package/src/infer/lsp.mjs +131 -0
- package/src/infer/session.cjs +105 -0
- package/src/infer/shadow.cjs +46 -0
- package/src/infer/worker.cjs +97 -0
- package/src/jsx-scan.mjs +86 -0
- package/src/load-config.mjs +303 -0
- package/src/map.mjs +110 -0
- package/src/mcp.mjs +195 -0
- package/src/meta-table.mjs +28 -0
- package/src/mutate.mjs +140 -0
- package/src/plugin-checks.mjs +89 -0
- package/src/plugins.mjs +172 -0
- package/src/project.mjs +219 -0
- package/src/props-checks.mjs +46 -0
- package/src/props-listener.mjs +38 -0
- package/src/react-checks.mjs +36 -0
- package/src/share-checks.mjs +74 -0
- package/src/site.mjs +19 -0
- package/src/slots.mjs +96 -0
- package/src/start.mjs +296 -0
- package/src/state-checks.mjs +213 -0
- package/src/style.mjs +320 -0
- package/src/surface-checks.mjs +218 -0
- package/src/tailwind.mjs +22 -0
- package/src/tasks.mjs +99 -0
- package/src/testdiff.mjs +157 -0
- package/src/tests.mjs +572 -0
- package/src/text-checks.mjs +172 -0
- package/src/tokens.mjs +24 -0
- package/src/trust.mjs +282 -0
- package/src/weft.mjs +66 -0
- package/src/workspace.mjs +105 -0
- package/ts-plugin/index.js +137 -0
- 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 }
|