@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.
Files changed (171) hide show
  1. package/README.md +38 -14
  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 +108 -0
  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 +174 -188
  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 +68 -444
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +199 -207
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +228 -224
  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 +142 -0
  74. package/src/deploy/files.mjs +266 -0
  75. package/src/deploy/fit.mjs +166 -0
  76. package/src/deploy/hosts.mjs +126 -0
  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 +387 -0
  81. package/src/deploy/needs.mjs +149 -0
  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 -42
  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 +144 -116
  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 +194 -92
  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
@@ -1,29 +1,22 @@
1
1
  // Prop inference (research 44). Facts come from syntax; the types come from whichever checker the host wraps, asked
2
2
  // about the call sites in the program's own terms. A round writes each component's annotation into its file's shadow;
3
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.
4
+ // as a prop). Rounds stop when nothing changes. A checker starts for inference or core box diagnostics.
5
5
 
6
- const { fileFacts, hashOf, MARK } = require('./facts.cjs')
7
- const { inGenerated, shadow, toShadow, toSource } = require('./shadow.cjs')
6
+ const { fileFacts, hashOf } = require('./facts.cjs')
7
+ const CORE_IMPORT = /['"]@weft-js\/core['"]/
8
+ const { shadow, toShadow } = require('./shadow.cjs')
8
9
 
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]
10
+ const { flows, collect, fromBody, infer } = require('./engine/flow.cjs')
11
+ const { conflicts, shapes, unprintable, errors, affected, run } = require('./engine/checks.cjs')
12
+ const { note: noteFinding, annotation, report, origin } = require('./engine/report.cjs')
20
13
 
21
14
  class Engine {
22
15
  /** @param {{ ts: any, read: (file: string) => string | undefined, routes?: (file: string) => boolean }} options */
23
16
  constructor({ ts, read, routes }) {
24
17
  this.ts = ts
25
18
  this.read = read
26
- this.routes = routes ?? (f => /\/src\/routes\//.test(f))
19
+ this.routes = routes ?? ((f) => /\/src\/routes\//.test(f))
27
20
  this.facts = new Map()
28
21
  this.texts = new Map()
29
22
  this.maps = new Map() // file → shadow, for files that differ from their source
@@ -38,7 +31,14 @@ class Engine {
38
31
 
39
32
  /** New text for a file. @returns whether anything about it changed */
40
33
  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 }
34
+ if (text === undefined) {
35
+ const had = this.facts.delete(file)
36
+ this.texts.delete(file)
37
+ this.maps.delete(file)
38
+ this.typed.delete(file)
39
+ this.flowCache.delete(file)
40
+ return had
41
+ }
42
42
  const before = this.facts.get(file)
43
43
  this.texts.set(file, text)
44
44
  const hash = hashOf(text)
@@ -48,18 +48,52 @@ class Engine {
48
48
  return true
49
49
  }
50
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))) }
51
+ comps() {
52
+ return [...this.facts.values()].flatMap((f) => f.comps)
53
+ }
54
+ /** Facts that need a checker: inferred props, bindings or possible core box errors. */
55
+ needed() {
56
+ return (
57
+ this.typedChecksNeeded() ||
58
+ [...this.facts.values()].some(
59
+ (f) => f.comps.length || f.sites.some((s) => s.attrs.some((a) => a.bind)),
60
+ )
61
+ )
62
+ }
63
+
64
+ /** Syntax only gates startup; the checker proves every W008 finding. */
65
+ boxPropsNeeded() {
66
+ const sites = [...this.facts.values()].some((facts) =>
67
+ facts.sites.some((site) => site.attrs.length),
68
+ )
69
+ return sites && [...this.texts.values()].some((text) => CORE_IMPORT.test(text))
70
+ }
71
+
72
+ /** Syntax only gates startup; TS2540 and resolved core symbols prove readonly writes. */
73
+ derivedWritesNeeded() {
74
+ return (
75
+ [...this.texts.values()].some((text) => CORE_IMPORT.test(text)) &&
76
+ [...this.texts.values()].some((text) => /\bvalue\b/.test(text))
77
+ )
78
+ }
79
+
80
+ /** Whether either core box diagnostic family needs the shared checker. */
81
+ typedChecksNeeded() {
82
+ return this.boxPropsNeeded() || this.derivedWritesNeeded()
83
+ }
54
84
 
55
85
  /** Files with a component or a site of one that import `file`: what they pass may have changed type. */
56
86
  dependents(file) {
57
87
  const base = file.replace(/^.*[\\/]/, '').replace(/\.[^.]*$/, '')
58
- const names = new Set(this.comps().map(c => c.name))
88
+ const names = new Set(this.comps().map((c) => c.name))
59
89
  const out = new Set()
60
90
  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 }
91
+ if (!f.comps.length && !f.sites.some((s) => names.has(s.tag))) continue
92
+ for (const { from } of f.imports.values())
93
+ if (from === base || from.endsWith(`/${base}`)) {
94
+ out.add(f.file)
95
+ break
96
+ }
63
97
  }
64
98
  return out
65
99
  }
@@ -67,38 +101,55 @@ class Engine {
67
101
  /** Whether a saved file can move anything: it holds a component or a site of one, or such a file imports it. */
68
102
  relevant(file) {
69
103
  if (!this.needed()) return false
104
+ if (this.typedChecksNeeded()) return true
70
105
  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
106
+ if (!facts || facts.comps.length || facts.sites.some((s) => s.attrs.some((a) => a.bind)))
107
+ return true
108
+ const names = new Set(this.comps().map((c) => c.name))
109
+ return (
110
+ facts.sites.some((s) => names.has(s.tag) || (this.flowing?.has(file) ?? false)) ||
111
+ this.dependents(file).size > 0
112
+ )
74
113
  }
75
114
 
76
115
  /** `comp` for a tag that needs no checker: a name in the same file, or a local wrap of one. */
77
116
  direct(facts, site) {
78
- const names = new Set(), todo = [site.tag.split('.')[0]]
117
+ const names = new Set(),
118
+ todo = [site.tag.split('.')[0]]
79
119
  while (todo.length) {
80
120
  const n = todo.pop()
81
121
  if (names.has(n)) continue
82
122
  names.add(n)
83
123
  todo.push(...(facts.wraps.get(n) ?? []))
84
124
  }
85
- return facts.comps.filter(c => names.has(c.name))
125
+ return facts.comps.filter((c) => names.has(c.name))
86
126
  }
87
127
 
88
- shadowOf(file) { return this.maps.get(file) }
128
+ shadowOf(file) {
129
+ return this.maps.get(file)
130
+ }
89
131
 
90
132
  edits(facts) {
91
133
  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
- }
134
+ for (const site of facts.sites)
135
+ for (const a of site.attrs) {
136
+ if (!a.bind) continue
137
+ if (a.raw)
138
+ edits.push(
139
+ { start: a.raw[0], end: a.raw[0] + 5, text: '' },
140
+ { start: a.span[1], end: a.span[1], text: '.value' },
141
+ )
142
+ }
96
143
  for (const c of facts.comps) {
97
144
  const type = this.state.get(c.id)?.type ?? 'any'
98
145
  const note = `: ${type}`
99
146
  if (c.written) edits.push({ start: c.written[0], end: c.written[1], text: note })
100
147
  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})` })
148
+ else
149
+ edits.push(
150
+ { start: c.paramSpan[0], end: c.paramSpan[0], text: '(' },
151
+ { start: c.nameEnd, end: c.nameEnd, text: `${note})` },
152
+ )
102
153
  }
103
154
  return edits
104
155
  }
@@ -108,12 +159,15 @@ class Engine {
108
159
  const changed = new Map()
109
160
  for (const [file, facts] of this.facts) {
110
161
  if (only && !only.has(file)) continue
111
- if (!facts.comps.length && !facts.sites.some(s => s.attrs.some(a => a.bind))) {
162
+ if (!facts.comps.length && !facts.sites.some((s) => s.attrs.some((a) => a.bind))) {
112
163
  if (this.maps.delete(file)) changed.set(file, this.texts.get(file))
113
164
  continue
114
165
  }
115
166
  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) }
167
+ if (this.maps.get(file)?.text !== map.text) {
168
+ this.maps.set(file, map)
169
+ changed.set(file, map.text)
170
+ }
117
171
  }
118
172
  if (changed.size) host.update(changed)
119
173
  return changed
@@ -133,392 +187,78 @@ class Engine {
133
187
  if (!reqs.length) return []
134
188
  this.stats.queries += reqs.length
135
189
  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]) })))
190
+ const at = (p) => (map ? toShadow(map, p) : p)
191
+ return host.query(
192
+ file,
193
+ reqs.map((r) => ({ kind: r.kind, start: at(r.span[0]), end: at(r.span[1]) })),
194
+ )
138
195
  }
139
196
 
140
197
  /** Who a tag renders when its name doesn't say: a component held in a variable, a map, a prop, an import. */
141
198
  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
199
+ return flows.call(this, host, force)
177
200
  }
178
201
 
179
202
  /** Every call site of every component, with what the checker says each attribute is. */
180
203
  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
204
+ return collect.call(this, host, force)
202
205
  }
203
206
 
204
207
  /** What the body alone says about `key`: what's left when no caller says, or a caller says `any`. */
205
208
  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
209
+ return fromBody.call(this, c, key, entry, cache)
219
210
  }
220
211
 
221
212
  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
213
+ return infer.call(this, c, sites, host)
307
214
  }
308
215
 
309
216
  /** The body can't take the union: try each caller's type alone, and leave the rest as errors at those callers. */
310
217
  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
- }
218
+ return conflicts.call(this, host, comps)
355
219
  }
356
220
 
357
221
  /** Callers that differ by a string prop: one object type per value, so the body can narrow on it. */
358
222
  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(' | ')
223
+ return shapes.call(this, st)
377
224
  }
378
225
 
379
226
  /** A type the checker printed in another scope may not be nameable here: then this component is `any`, and says so. */
380
227
  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
- }
228
+ return unprintable.call(this, host, comps)
392
229
  }
393
230
 
394
231
  /** The checker's errors in a file, in source positions, without what the engine already said better. */
395
232
  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
- })
233
+ return errors.call(this, host, file)
405
234
  }
406
235
 
407
236
  /** Files whose types depend on inference: where a component is, and where it's rendered. */
408
237
  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
238
+ return affected.call(this)
412
239
  }
413
240
 
414
241
  /** @returns {Map<string, {code: string, severity?: string, start: number, end: number, why: string, help?: string, edit?: object}[]>} */
415
242
  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
243
+ return run.call(this, host, changed)
450
244
  }
451
245
 
452
246
  note(file, finding) {
453
- if (!this.findings.has(file)) this.findings.set(file, [])
454
- this.findings.get(file).push(finding)
247
+ return noteFinding.call(this, file, finding)
455
248
  }
456
249
 
457
250
  /** The source edits that write a type down. */
458
251
  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})` }]
252
+ return annotation.call(this, c, type)
463
253
  }
464
254
 
465
255
  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
- }
256
+ return report.call(this, c)
493
257
  }
494
258
 
495
259
  /** Where an `any` started, by following names in the file: `JSON.parse(raw)`, an untyped parameter, an import. */
496
260
  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
261
+ return origin.call(this, file, span)
522
262
  }
523
263
  }
524
264