@weft-js/cli 0.1.1 → 0.1.3

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 (171) hide show
  1. package/README.md +534 -54
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. 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 }