@weft-js/cli 0.1.0 → 0.1.2
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/README.md +38 -14
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +108 -0
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +174 -188
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +68 -444
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +199 -207
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +228 -224
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +142 -0
- package/src/deploy/files.mjs +266 -0
- package/src/deploy/fit.mjs +166 -0
- package/src/deploy/hosts.mjs +126 -0
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +387 -0
- package/src/deploy/needs.mjs +149 -0
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -42
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +144 -116
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +194 -92
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- package/ts-plugin/proxy.js +144 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const { inGenerated, toShadow, toSource } = require('../shadow.cjs')
|
|
2
|
+
const { ROUNDS, quote, union, overlaps } = require('./utils.cjs')
|
|
3
|
+
|
|
4
|
+
function conflicts(host, comps) {
|
|
5
|
+
for (const c of comps) {
|
|
6
|
+
const st = this.state.get(c.id)
|
|
7
|
+
if (!st?.keys) continue
|
|
8
|
+
for (const [key, k] of st.keys) {
|
|
9
|
+
if (k.tally.size < 2 || st.pins.has(key)) continue
|
|
10
|
+
const map = this.maps.get(c.file)
|
|
11
|
+
const uses = k.uses.map((u) => u.span.map((p) => toShadow(map, p)))
|
|
12
|
+
const hurt = () =>
|
|
13
|
+
this.diag(host, c.file).filter((d) =>
|
|
14
|
+
uses.some((u) => overlaps(u, [d.start, d.start + d.length])),
|
|
15
|
+
)
|
|
16
|
+
if (!hurt().length) continue
|
|
17
|
+
const shape = this.shapes(st)
|
|
18
|
+
if (shape) {
|
|
19
|
+
st.shape = shape
|
|
20
|
+
this.state.set(c.id, this.infer(c, this.lastSites.get(c.id), host))
|
|
21
|
+
this.publish(host, new Set([c.file]))
|
|
22
|
+
const m2 = this.maps.get(c.file)
|
|
23
|
+
const u2 = k.uses.map((u) => u.span.map((p) => toShadow(m2, p)))
|
|
24
|
+
if (
|
|
25
|
+
!this.diag(host, c.file).some((d) =>
|
|
26
|
+
u2.some((u) => overlaps(u, [d.start, d.start + d.length])),
|
|
27
|
+
)
|
|
28
|
+
) {
|
|
29
|
+
st.shape = shape
|
|
30
|
+
break
|
|
31
|
+
}
|
|
32
|
+
st.shape = undefined
|
|
33
|
+
this.state.set(c.id, this.infer(c, this.lastSites.get(c.id), host))
|
|
34
|
+
this.publish(host, new Set([c.file]))
|
|
35
|
+
}
|
|
36
|
+
const order = [...k.tally].sort((a, b) => b[1] - a[1]).map(([t]) => t)
|
|
37
|
+
let chosen = null
|
|
38
|
+
for (const t of order) {
|
|
39
|
+
st.pins.set(key, t)
|
|
40
|
+
this.state.set(c.id, this.infer(c, this.lastSites.get(c.id), host))
|
|
41
|
+
this.publish(host, new Set([c.file]))
|
|
42
|
+
const m2 = this.maps.get(c.file)
|
|
43
|
+
const u2 = k.uses.map((u) => u.span.map((p) => toShadow(m2, p)))
|
|
44
|
+
if (
|
|
45
|
+
!this.diag(host, c.file).some((d) =>
|
|
46
|
+
u2.some((u) => overlaps(u, [d.start, d.start + d.length])),
|
|
47
|
+
)
|
|
48
|
+
) {
|
|
49
|
+
chosen = t
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (chosen) {
|
|
54
|
+
st.pins.set(key, chosen)
|
|
55
|
+
const next = this.infer(c, this.lastSites.get(c.id), host)
|
|
56
|
+
this.state.set(c.id, next)
|
|
57
|
+
this.publish(host, new Set([c.file]))
|
|
58
|
+
next.odd = [
|
|
59
|
+
...(next.odd ?? []),
|
|
60
|
+
...k.given
|
|
61
|
+
.filter((v) => !v.any && v.text !== chosen)
|
|
62
|
+
.map((v) => ({
|
|
63
|
+
key,
|
|
64
|
+
type: v.text,
|
|
65
|
+
want: chosen,
|
|
66
|
+
...v.at,
|
|
67
|
+
use: k.uses.find((u) => u.as !== 'read') ?? k.uses[0],
|
|
68
|
+
})),
|
|
69
|
+
]
|
|
70
|
+
} else {
|
|
71
|
+
st.pins.delete(key)
|
|
72
|
+
this.state.set(c.id, this.infer(c, this.lastSites.get(c.id), host))
|
|
73
|
+
this.publish(host, new Set([c.file]))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function shapes(st) {
|
|
80
|
+
const keys = [...st.keys]
|
|
81
|
+
const tag = keys.find(([, k]) => {
|
|
82
|
+
const lits = k.given.map((v) => v.exact)
|
|
83
|
+
return lits.length && lits.every((l) => l?.startsWith('"')) && new Set(lits).size > 1
|
|
84
|
+
})
|
|
85
|
+
if (!tag) return null
|
|
86
|
+
const groups = new Map()
|
|
87
|
+
for (const v of tag[1].given) {
|
|
88
|
+
if (!groups.has(v.exact)) groups.set(v.exact, new Set())
|
|
89
|
+
groups.get(v.exact).add(`${v.at.file}:${v.at.whole?.[0]}`)
|
|
90
|
+
}
|
|
91
|
+
return [...groups]
|
|
92
|
+
.map(([lit]) => {
|
|
93
|
+
const parts = [`${quote(tag[0])}: ${lit}`]
|
|
94
|
+
for (const [key, k] of keys) {
|
|
95
|
+
if (key === tag[0]) continue
|
|
96
|
+
const near = tag[1].given.filter((v) => v.exact === lit).map((v) => v.at)
|
|
97
|
+
const mine = k.given.filter((v) =>
|
|
98
|
+
near.some((n) => n.tagAt === v.at.tagAt && n.file === v.at.file),
|
|
99
|
+
)
|
|
100
|
+
if (mine.length)
|
|
101
|
+
parts.push(
|
|
102
|
+
`${quote(key)}${mine.length < near.length ? '?' : ''}: ${union(mine.map((v) => v.text))}`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
return `{ ${parts.join('; ')} }`
|
|
106
|
+
})
|
|
107
|
+
.join(' | ')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function unprintable(host, comps) {
|
|
111
|
+
for (const c of comps) {
|
|
112
|
+
const map = this.maps.get(c.file)
|
|
113
|
+
if (!map || !this.state.get(c.id)?.keys) continue
|
|
114
|
+
for (const d of this.diag(host, c.file)) {
|
|
115
|
+
const at = toSource(map, d.start)
|
|
116
|
+
if (!inGenerated(map, d.start, d.length) || at !== c.nameEnd) continue
|
|
117
|
+
const st = this.state.get(c.id)
|
|
118
|
+
st.unnamed = d.text
|
|
119
|
+
st.type = 'any'
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function errors(host, file) {
|
|
125
|
+
const map = this.maps.get(file)
|
|
126
|
+
const said = (this.findings.get(file) ?? []).filter((f) => f.code === 'W084')
|
|
127
|
+
return this.diag(host, file).flatMap((d) => {
|
|
128
|
+
if (map && inGenerated(map, d.start, d.length)) return []
|
|
129
|
+
const start = map ? toSource(map, d.start) : d.start
|
|
130
|
+
const end = map ? toSource(map, d.start + d.length) : d.start + d.length
|
|
131
|
+
if (said.some((f) => overlaps([f.start, f.end], [start, Math.max(end, start + 1)]))) return []
|
|
132
|
+
return [{ code: `TS${d.code}`, start, end, why: d.text, help: 'Fix the type error', ...d.weft }]
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function affected() {
|
|
137
|
+
const files = new Set(this.maps.keys())
|
|
138
|
+
const derived = this.derivedWritesNeeded()
|
|
139
|
+
if (derived || this.boxPropsNeeded())
|
|
140
|
+
for (const facts of this.facts.values())
|
|
141
|
+
if (derived || facts.sites.length) files.add(facts.file)
|
|
142
|
+
for (const sites of this.lastSites?.values() ?? []) for (const s of sites) files.add(s.file)
|
|
143
|
+
return files
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function run(host, changed) {
|
|
147
|
+
if (this.typedChecksNeeded()) this.diags.clear()
|
|
148
|
+
this.findings = new Map()
|
|
149
|
+
const comps = this.comps()
|
|
150
|
+
if (!this.needed()) {
|
|
151
|
+
this.publish(host)
|
|
152
|
+
return this.findings
|
|
153
|
+
}
|
|
154
|
+
const first = this.publish(host)
|
|
155
|
+
let force = changed ? new Set(changed) : undefined
|
|
156
|
+
if (force) {
|
|
157
|
+
for (const f of changed) for (const d of this.dependents(f)) force.add(d)
|
|
158
|
+
for (const f of first.keys()) force.add(f)
|
|
159
|
+
}
|
|
160
|
+
const touched = new Set(force)
|
|
161
|
+
const sitesOf = (id) => (this.lastSites?.get(id) ?? []).map((s) => s.file)
|
|
162
|
+
let dirty = comps
|
|
163
|
+
for (let round = 0; round < ROUNDS; round++) {
|
|
164
|
+
this.stats.rounds++
|
|
165
|
+
const sites = this.collect(host, force)
|
|
166
|
+
this.lastSites = sites
|
|
167
|
+
if (force)
|
|
168
|
+
dirty = comps.filter(
|
|
169
|
+
(c) =>
|
|
170
|
+
!this.state.has(c.id) ||
|
|
171
|
+
force.has(c.file) ||
|
|
172
|
+
sites.get(c.id).some((s) => force.has(s.file)),
|
|
173
|
+
)
|
|
174
|
+
const moved = []
|
|
175
|
+
for (const c of dirty) {
|
|
176
|
+
const st = this.infer(c, sites.get(c.id), host)
|
|
177
|
+
if (st.type !== this.state.get(c.id)?.type) moved.push(c)
|
|
178
|
+
this.state.set(c.id, st)
|
|
179
|
+
}
|
|
180
|
+
if (!moved.length) break
|
|
181
|
+
const out = this.publish(host)
|
|
182
|
+
force = new Set([...out.keys(), ...moved.flatMap((c) => [c.file, ...sitesOf(c.id)])])
|
|
183
|
+
for (const f of force) touched.add(f)
|
|
184
|
+
}
|
|
185
|
+
this.conflicts(host, dirty)
|
|
186
|
+
this.unprintable(host, dirty)
|
|
187
|
+
this.publish(host)
|
|
188
|
+
for (const c of comps) this.report(c)
|
|
189
|
+
this.touched = touched
|
|
190
|
+
return this.findings
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { conflicts, shapes, unprintable, errors, affected, run }
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
const { toShadow } = require('../shadow.cjs')
|
|
2
|
+
const { MAX_LITERALS, quote, unique, union, sameFile } = require('./utils.cjs')
|
|
3
|
+
|
|
4
|
+
function findFlows(host, force) {
|
|
5
|
+
const out = new Map() // "file:start" → comps
|
|
6
|
+
const comps = this.comps()
|
|
7
|
+
const open = comps.filter(
|
|
8
|
+
(c) =>
|
|
9
|
+
c.exported ||
|
|
10
|
+
this.facts.get(c.file).escaped.has(c.name) ||
|
|
11
|
+
[...this.facts.values()].some((f) => [...f.wraps.values()].some((l) => l.includes(c.name))),
|
|
12
|
+
)
|
|
13
|
+
if (!open.length) return out
|
|
14
|
+
const names = new Set(open.map((c) => c.name))
|
|
15
|
+
for (const [file, facts] of this.facts) {
|
|
16
|
+
const cross = (s) => {
|
|
17
|
+
const head = s.tag.split('.')[0]
|
|
18
|
+
const imported = facts.imports.get(head)
|
|
19
|
+
if (imported)
|
|
20
|
+
return names.has(imported.name) || imported.name === 'default' || names.has(head)
|
|
21
|
+
return !facts.comps.some((c) => c.name === s.tag)
|
|
22
|
+
}
|
|
23
|
+
const held = this.flowCache.get(file)
|
|
24
|
+
if (force && !force.has(file) && held?.facts === facts) {
|
|
25
|
+
for (const [k, v] of held.hits) out.set(k, v)
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
const sites = facts.sites.filter((s) => !this.direct(facts, s).length && cross(s))
|
|
29
|
+
const hits = new Map()
|
|
30
|
+
this.flowCache.set(file, { facts, hits })
|
|
31
|
+
if (!sites.length) continue
|
|
32
|
+
const res = this.ask(
|
|
33
|
+
host,
|
|
34
|
+
file,
|
|
35
|
+
sites.map((s) => ({ kind: 'decls', span: s.tagSpan })),
|
|
36
|
+
)
|
|
37
|
+
sites.forEach((s, i) => {
|
|
38
|
+
const found = []
|
|
39
|
+
for (const d of res[i].decls ?? []) {
|
|
40
|
+
for (const c of open) {
|
|
41
|
+
if (!sameFile(c.file, d.file)) continue
|
|
42
|
+
const map = this.maps.get(c.file)
|
|
43
|
+
if ((map ? toShadow(map, c.bodySpan[1]) : c.bodySpan[1]) === d.end && !found.includes(c))
|
|
44
|
+
found.push(c)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (found.length) {
|
|
48
|
+
hits.set(`${file}:${s.span[0]}`, found)
|
|
49
|
+
out.set(`${file}:${s.span[0]}`, found)
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
return out
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function collect(host, force) {
|
|
57
|
+
const flows = this.flows(host, force)
|
|
58
|
+
const sites = new Map(this.comps().map((c) => [c.id, []]))
|
|
59
|
+
for (const [file, facts] of this.facts) {
|
|
60
|
+
const targets = facts.sites.map((s) => {
|
|
61
|
+
const d = this.direct(facts, s)
|
|
62
|
+
return d.length ? d : (flows.get(`${file}:${s.span[0]}`) ?? [])
|
|
63
|
+
})
|
|
64
|
+
let held = this.typed.get(file)
|
|
65
|
+
if (!held || held.facts !== facts || !force || force.has(file)) {
|
|
66
|
+
const reqs = [],
|
|
67
|
+
back = []
|
|
68
|
+
facts.sites.forEach((s, i) => {
|
|
69
|
+
if (!targets[i].length) return
|
|
70
|
+
for (const a of s.attrs)
|
|
71
|
+
if (a.span) {
|
|
72
|
+
back.push(a)
|
|
73
|
+
reqs.push({ kind: 'type', span: a.span })
|
|
74
|
+
}
|
|
75
|
+
for (const sp of s.spreads) {
|
|
76
|
+
back.push(sp)
|
|
77
|
+
reqs.push({ kind: 'spread', span: sp.span })
|
|
78
|
+
}
|
|
79
|
+
if (s.children?.span) {
|
|
80
|
+
back.push(s.children)
|
|
81
|
+
reqs.push({ kind: 'type', span: s.children.span })
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
const res = this.ask(host, file, reqs)
|
|
85
|
+
held = { facts, typed: new Map(back.map((a, i) => [a, res[i]])) }
|
|
86
|
+
this.typed.set(file, held)
|
|
87
|
+
}
|
|
88
|
+
const { typed } = held
|
|
89
|
+
facts.sites.forEach((s, i) => {
|
|
90
|
+
for (const c of targets[i]) sites.get(c.id).push({ file, site: s, typed })
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
return sites
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function fromBody(c, key, entry, cache) {
|
|
97
|
+
const q = (kind) => (span) => cache(kind, span)
|
|
98
|
+
const typeOf = q('type'),
|
|
99
|
+
ctx = q('context')
|
|
100
|
+
let member = null
|
|
101
|
+
for (const u of entry.uses) {
|
|
102
|
+
if (u.as === 'number') return 'number'
|
|
103
|
+
if (u.as === 'call')
|
|
104
|
+
return `(${u.args.map((a, i) => `arg${i}: ${typeOf(a).text}`).join(', ')}) => any`
|
|
105
|
+
if (u.as === 'context') {
|
|
106
|
+
const t = ctx(u.span)
|
|
107
|
+
if (t && !t.any && t.text !== 'unknown') return t.text.replace(/ \| undefined$/, '')
|
|
108
|
+
}
|
|
109
|
+
if (u.as === 'write') {
|
|
110
|
+
const t = typeOf(u.value)
|
|
111
|
+
if (!t.any) return t.text
|
|
112
|
+
}
|
|
113
|
+
if (u.as === 'member') member = u.member
|
|
114
|
+
}
|
|
115
|
+
if (member)
|
|
116
|
+
return /^(length|map|filter|forEach|slice|find|some|every|join|reduce)$/.test(member)
|
|
117
|
+
? 'any[]'
|
|
118
|
+
: `{ ${member}: any }`
|
|
119
|
+
if (entry.fallback) {
|
|
120
|
+
const t = typeOf(entry.fallback)
|
|
121
|
+
if (!t.any) return t.text
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function siteValues(engine, c, sites) {
|
|
127
|
+
const seen = new Map() // key → [{ text, exact, any, untypedFn, at }]
|
|
128
|
+
const add = (key, v) => {
|
|
129
|
+
if (!seen.has(key)) seen.set(key, [])
|
|
130
|
+
seen.get(key).push(v)
|
|
131
|
+
}
|
|
132
|
+
for (const { file, site, typed } of sites) {
|
|
133
|
+
const self = site.in === c.name && file === c.file
|
|
134
|
+
for (const a of site.attrs) {
|
|
135
|
+
const r = a.span ? typed.get(a) : null
|
|
136
|
+
const held = a.value && engine.facts.get(file).comps.some((x) => x.name === a.value)
|
|
137
|
+
const v = held
|
|
138
|
+
? { text: `typeof ${a.value}` }
|
|
139
|
+
: a.literal === 'true'
|
|
140
|
+
? { text: 'boolean' }
|
|
141
|
+
: a.literal === 'string'
|
|
142
|
+
? { text: 'string', exact: a.exact }
|
|
143
|
+
: (r ?? { text: 'any', any: true })
|
|
144
|
+
if (self) continue // a component rendering itself says nothing new about its props
|
|
145
|
+
add(a.name, {
|
|
146
|
+
...v,
|
|
147
|
+
untypedFn: a.untypedFn,
|
|
148
|
+
at: {
|
|
149
|
+
file,
|
|
150
|
+
span: a.span ?? a.whole ?? site.tagSpan,
|
|
151
|
+
whole: a.whole ?? site.tagSpan,
|
|
152
|
+
tag: site.tag,
|
|
153
|
+
tagAt: site.tagSpan[0],
|
|
154
|
+
},
|
|
155
|
+
self,
|
|
156
|
+
literal: a.literal,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
for (const sp of site.spreads) {
|
|
160
|
+
const r = typed.get(sp)
|
|
161
|
+
if (!self)
|
|
162
|
+
for (const p of r?.props ?? [])
|
|
163
|
+
add(p.name, {
|
|
164
|
+
text: p.text,
|
|
165
|
+
any: p.any,
|
|
166
|
+
at: { file, span: sp.span, tag: site.tag },
|
|
167
|
+
self,
|
|
168
|
+
optional: p.optional,
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
if (site.children && !self) {
|
|
172
|
+
const r = site.children.span ? typed.get(site.children) : null
|
|
173
|
+
add('children', {
|
|
174
|
+
...(r ?? { text: 'unknown' }),
|
|
175
|
+
untypedFn: site.children.untypedFn,
|
|
176
|
+
at: { file, span: site.tagSpan, tag: site.tag },
|
|
177
|
+
self,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return seen
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function inferKeys({ engine, c, fwd, outer, st, seen, cache }) {
|
|
186
|
+
const named = new Set(c.info.keys.keys())
|
|
187
|
+
const all = fwd.length ? named : new Set([...seen.keys(), ...named])
|
|
188
|
+
const parts = []
|
|
189
|
+
for (const key of all) {
|
|
190
|
+
const entry = c.info.keys.get(key) ?? { uses: [] }
|
|
191
|
+
const given = seen.get(key) ?? []
|
|
192
|
+
const real = given.filter((v) => !v.any)
|
|
193
|
+
const body =
|
|
194
|
+
given.every((v) => v.any || v.untypedFn) || !given.length
|
|
195
|
+
? engine.fromBody(c, key, entry, cache)
|
|
196
|
+
: null
|
|
197
|
+
let text
|
|
198
|
+
const lits = real.filter((v) => v.exact?.startsWith('"'))
|
|
199
|
+
if (
|
|
200
|
+
real.length &&
|
|
201
|
+
lits.length === real.length &&
|
|
202
|
+
unique(lits.map((v) => v.exact)).length > 1 &&
|
|
203
|
+
unique(lits.map((v) => v.exact)).length <= MAX_LITERALS
|
|
204
|
+
)
|
|
205
|
+
text = union(lits.map((v) => v.exact))
|
|
206
|
+
else text = body ?? union(real.map((v) => v.text))
|
|
207
|
+
const distinct = unique(real.map((v) => v.text))
|
|
208
|
+
const tally = new Map(distinct.map((t) => [t, real.filter((v) => v.text === t).length]))
|
|
209
|
+
const number =
|
|
210
|
+
entry.uses.some((u) => u.as === 'number') && !tally.has('number') && distinct.length
|
|
211
|
+
? 'number'
|
|
212
|
+
: null
|
|
213
|
+
if (number) tally.set(number, 0)
|
|
214
|
+
if (st.pins.has(key)) text = st.pins.get(key)
|
|
215
|
+
else if (number) text = union([text, number])
|
|
216
|
+
if (given.some((v) => v.any) && !real.length)
|
|
217
|
+
st.anyFrom.push(...given.filter((v) => v.any).map((v) => ({ key, ...v.at, body: !!body })))
|
|
218
|
+
if (!text) {
|
|
219
|
+
if (given.length) text = 'any'
|
|
220
|
+
else if (entry.fallback) text = 'any'
|
|
221
|
+
else if (outer.length) text = 'undefined'
|
|
222
|
+
else text = 'any'
|
|
223
|
+
}
|
|
224
|
+
const passed = outer.filter(
|
|
225
|
+
(s) =>
|
|
226
|
+
s.site.attrs.some((a) => a.name === key) ||
|
|
227
|
+
(key === 'children' && s.site.children) ||
|
|
228
|
+
s.site.spreads.some((sp) =>
|
|
229
|
+
s.typed.get(sp)?.props?.some((p) => p.name === key && !p.optional),
|
|
230
|
+
),
|
|
231
|
+
)
|
|
232
|
+
const opaque = outer.some((s) => s.site.spreads.some((sp) => s.typed.get(sp)?.any))
|
|
233
|
+
const required =
|
|
234
|
+
outer.length &&
|
|
235
|
+
passed.length === outer.length &&
|
|
236
|
+
!entry.fallback &&
|
|
237
|
+
!opaque &&
|
|
238
|
+
text !== 'undefined'
|
|
239
|
+
const onlySelf = !outer.length
|
|
240
|
+
const optional = onlySelf ? !!entry.fallback : !required
|
|
241
|
+
st.keys.set(key, { text, optional, given, tally, uses: entry.uses })
|
|
242
|
+
parts.push(`${quote(key)}${optional ? '?' : ''}: ${text}`)
|
|
243
|
+
}
|
|
244
|
+
return { named, parts }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function infer(c, sites, host) {
|
|
248
|
+
const facts = this.facts.get(c.file)
|
|
249
|
+
const prev = this.state.get(c.id) ?? {}
|
|
250
|
+
const st = {
|
|
251
|
+
keys: new Map(),
|
|
252
|
+
sites: sites.length,
|
|
253
|
+
pins: prev.pins ?? new Map(),
|
|
254
|
+
shape: prev.shape,
|
|
255
|
+
anyFrom: [],
|
|
256
|
+
noCallers: false,
|
|
257
|
+
}
|
|
258
|
+
if (this.routes(c.file) && c.isDefault) {
|
|
259
|
+
st.type = `import('@weft-js/start').RouteProps${facts.exportsLoad ? '<typeof load>' : ''}`
|
|
260
|
+
st.route = true
|
|
261
|
+
return st
|
|
262
|
+
}
|
|
263
|
+
const outer = sites.filter((s) => !(s.site.in === c.name && s.file === c.file))
|
|
264
|
+
if (!outer.length) st.noCallers = true
|
|
265
|
+
|
|
266
|
+
const memo = new Map()
|
|
267
|
+
const cache = (kind, span) => {
|
|
268
|
+
const k = `${kind}:${span}`
|
|
269
|
+
if (!memo.has(k)) memo.set(k, this.ask(host, c.file, [{ kind, span }])[0])
|
|
270
|
+
return memo.get(k)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const seen = siteValues(this, c, sites)
|
|
274
|
+
|
|
275
|
+
const fwd = c.forwards
|
|
276
|
+
const { named, parts } = inferKeys({ engine: this, c, fwd, outer, st, seen, cache })
|
|
277
|
+
const own = parts.length ? `{ ${parts.join('; ')} }` : '{}'
|
|
278
|
+
if (fwd.length) {
|
|
279
|
+
const f = fwd[0]
|
|
280
|
+
const base = `Parameters<typeof ${f.tag}>[0]`
|
|
281
|
+
const names = unique([...named, ...f.given])
|
|
282
|
+
const taken = names.map((n) => JSON.stringify(n)).join(' | ')
|
|
283
|
+
const left = names.length ? `Omit<${base}, ${taken}>` : base
|
|
284
|
+
const loose = f.given.filter((n) => !named.has(n))
|
|
285
|
+
const given = loose.length
|
|
286
|
+
? ` & Partial<Pick<${base}, Extract<keyof ${base}, ${loose.map((n) => JSON.stringify(n)).join(' | ')}>>>`
|
|
287
|
+
: ''
|
|
288
|
+
st.type = parts.length ? `${left}${given} & ${own}` : `${left}${given}`
|
|
289
|
+
} else st.type = c.info.opaque ? 'any' : (st.shape ?? own)
|
|
290
|
+
return st
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = { flows: findFlows, collect, fromBody, infer }
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const { MARK } = require('../facts.cjs')
|
|
2
|
+
|
|
3
|
+
function note(file, finding) {
|
|
4
|
+
if (!this.findings.has(file)) this.findings.set(file, [])
|
|
5
|
+
this.findings.get(file).push(finding)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function annotation(c, type) {
|
|
9
|
+
const text = `: ${MARK} ${type}`
|
|
10
|
+
if (c.written) return [{ start: c.written[0], end: c.written[1], text }]
|
|
11
|
+
if (c.parens) return [{ start: c.nameEnd, end: c.nameEnd, text }]
|
|
12
|
+
return [
|
|
13
|
+
{ start: c.paramSpan[0], end: c.paramSpan[0], text: '(' },
|
|
14
|
+
{ start: c.nameEnd, end: c.nameEnd, text: `${text})` },
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function oddFindings(c, st, line) {
|
|
19
|
+
for (const o of st.odd ?? []) {
|
|
20
|
+
const use = o.use?.span ? this.texts.get(c.file).slice(...o.use.span) : o.key
|
|
21
|
+
this.note(o.file, {
|
|
22
|
+
code: 'W084',
|
|
23
|
+
start: o.whole[0],
|
|
24
|
+
end: o.whole[1],
|
|
25
|
+
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`,
|
|
26
|
+
help: `Pass ${o.want} here, or change \`${c.name}\` to handle ${o.type} (a type parameter, if the two props must agree)`,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function report(c) {
|
|
32
|
+
const st = this.state.get(c.id)
|
|
33
|
+
if (!st || st.route) return
|
|
34
|
+
const [start, end] = c.nameSpan
|
|
35
|
+
const line = (file, pos) => this.texts.get(file).slice(0, pos).split('\n').length
|
|
36
|
+
if (st.unnamed)
|
|
37
|
+
this.note(c.file, {
|
|
38
|
+
code: 'W085',
|
|
39
|
+
severity: 'warning',
|
|
40
|
+
start,
|
|
41
|
+
end,
|
|
42
|
+
why: `\`${c.name}\`'s inferred props name a type this scope can't see, so they fell back to \`any\`: ${st.unnamed}`,
|
|
43
|
+
help: 'Move the type to module scope or export it, or annotate the parameter',
|
|
44
|
+
})
|
|
45
|
+
const exposed = c.exported
|
|
46
|
+
if (st.noCallers && !exposed) {
|
|
47
|
+
const escaped = this.facts.get(c.file).escaped.has(c.name)
|
|
48
|
+
this.note(
|
|
49
|
+
c.file,
|
|
50
|
+
escaped
|
|
51
|
+
? {
|
|
52
|
+
code: 'W083',
|
|
53
|
+
severity: 'warning',
|
|
54
|
+
start,
|
|
55
|
+
end,
|
|
56
|
+
why: `\`${c.name}\` is used as a value and no JSX site for it could be traced, so its props are \`any\``,
|
|
57
|
+
help: `Annotate its parameter: function ${c.name}(props: { ... })`,
|
|
58
|
+
}
|
|
59
|
+
: {
|
|
60
|
+
code: 'W080',
|
|
61
|
+
start,
|
|
62
|
+
end,
|
|
63
|
+
why: `\`${c.name}\` has an untyped parameter and nothing renders it, so its props have nothing to be inferred from`,
|
|
64
|
+
help: `Render it (\`<${c.name} ... />\`), delete it, or annotate the parameter: function ${c.name}(props: { ... })`,
|
|
65
|
+
},
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
if (exposed) {
|
|
69
|
+
const type = st.type
|
|
70
|
+
const edits = this.annotation(c, type)
|
|
71
|
+
const bad = st.noCallers
|
|
72
|
+
if (!c.written)
|
|
73
|
+
this.note(c.file, {
|
|
74
|
+
code: 'W081',
|
|
75
|
+
start,
|
|
76
|
+
end,
|
|
77
|
+
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`,
|
|
78
|
+
help: `\`weft fix\` writes the inferred type: ${c.name}(props: ${type})`,
|
|
79
|
+
edits: bad ? undefined : edits,
|
|
80
|
+
safe: !bad,
|
|
81
|
+
})
|
|
82
|
+
else if (c.writtenType.replace(MARK, '').trim() !== type && !bad)
|
|
83
|
+
this.note(c.file, {
|
|
84
|
+
code: 'W082',
|
|
85
|
+
start,
|
|
86
|
+
end,
|
|
87
|
+
why: `\`${c.name}\`'s written prop type no longer matches its callers: it says ${c.writtenType.replace(MARK, '').trim()}, they need ${type}`,
|
|
88
|
+
help: '`weft fix` rewrites it (a diff to review), or delete the /*inferred*/ comment to keep the type you have',
|
|
89
|
+
edits,
|
|
90
|
+
safe: true,
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
for (const a of st.anyFrom) {
|
|
94
|
+
const origin = this.origin(a.file, a.span)
|
|
95
|
+
this.note(c.file, {
|
|
96
|
+
code: 'W083',
|
|
97
|
+
severity: 'warning',
|
|
98
|
+
start,
|
|
99
|
+
end,
|
|
100
|
+
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}` : ''}`,
|
|
101
|
+
help: 'Give that source a type, or annotate the prop',
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
oddFindings.call(this, c, st, line)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function originOf(file, span) {
|
|
108
|
+
const ts = this.ts
|
|
109
|
+
const text = this.texts.get(file)
|
|
110
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
111
|
+
const lineOf = (n) => sf.getLineAndCharacterOfPosition(n.getStart(sf)).line + 1
|
|
112
|
+
let node = null
|
|
113
|
+
const find = (n) => {
|
|
114
|
+
if (n.getStart(sf) <= span[0] && n.end >= span[1]) {
|
|
115
|
+
node = n
|
|
116
|
+
ts.forEachChild(n, find)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
ts.forEachChild(sf, find)
|
|
120
|
+
for (let i = 0; node && i < 6; i++) {
|
|
121
|
+
if (ts.isPropertyAccessExpression(node)) node = node.expression
|
|
122
|
+
if (
|
|
123
|
+
ts.isAsExpression(node) ||
|
|
124
|
+
ts.isParenthesizedExpression(node) ||
|
|
125
|
+
ts.isNonNullExpression(node)
|
|
126
|
+
) {
|
|
127
|
+
node = node.expression
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (ts.isCallExpression(node))
|
|
131
|
+
return `\`${node.getText(sf).slice(0, 48)}\` (line ${lineOf(node)})`
|
|
132
|
+
if (!ts.isIdentifier(node)) return `\`${node.getText(sf).slice(0, 48)}\` (line ${lineOf(node)})`
|
|
133
|
+
let decl = null
|
|
134
|
+
const look = (n) => {
|
|
135
|
+
if (
|
|
136
|
+
(ts.isVariableDeclaration(n) ||
|
|
137
|
+
ts.isParameter(n) ||
|
|
138
|
+
ts.isImportSpecifier(n) ||
|
|
139
|
+
ts.isImportClause(n)) &&
|
|
140
|
+
n.name?.getText(sf) === node.text &&
|
|
141
|
+
n.pos < node.pos
|
|
142
|
+
)
|
|
143
|
+
decl = n
|
|
144
|
+
ts.forEachChild(n, look)
|
|
145
|
+
}
|
|
146
|
+
ts.forEachChild(sf, look)
|
|
147
|
+
if (!decl) return `\`${node.text}\``
|
|
148
|
+
if (ts.isParameter(decl))
|
|
149
|
+
return `the parameter \`${node.text}\`, which has no type (line ${lineOf(decl)})`
|
|
150
|
+
if (ts.isImportSpecifier(decl) || ts.isImportClause(decl))
|
|
151
|
+
return `\`${node.text}\`, imported at line ${lineOf(decl)}`
|
|
152
|
+
if (!decl.initializer) return `\`${node.text}\` (line ${lineOf(decl)})`
|
|
153
|
+
node = decl.initializer
|
|
154
|
+
}
|
|
155
|
+
return null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { note, annotation, report, origin: originOf }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const ROUNDS = 5
|
|
2
|
+
const MAX_LITERALS = 8
|
|
3
|
+
|
|
4
|
+
const quote = (key) => (/^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key))
|
|
5
|
+
const unique = (list) => [...new Set(list)]
|
|
6
|
+
const union = (list) => {
|
|
7
|
+
const parts = unique(list.filter(Boolean))
|
|
8
|
+
return parts.length
|
|
9
|
+
? parts.map((t) => (/=>/.test(t) && !/^\(.*\)$/.test(t) ? `(${t})` : t)).join(' | ')
|
|
10
|
+
: null
|
|
11
|
+
}
|
|
12
|
+
const sameFile = (a, b) => a === b || a.toLowerCase() === b.toLowerCase()
|
|
13
|
+
const overlaps = (a, b) => a[0] < b[1] && b[0] < a[1]
|
|
14
|
+
|
|
15
|
+
module.exports = { ROUNDS, MAX_LITERALS, quote, unique, union, sameFile, overlaps }
|