@weft-js/cli 0.1.1 → 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 +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,109 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, relative } from 'node:path'
3
+ import ts from 'typescript'
4
+ import { local } from '../alias.mjs'
5
+ import {
6
+ assertingFunctions,
7
+ contains,
8
+ moduleKey,
9
+ parse,
10
+ resolveImport,
11
+ rootName,
12
+ unwrap,
13
+ walk,
14
+ } from './syntax.mjs'
15
+
16
+ export function importsOf(sf, abs) {
17
+ const imports = new Map() // local name → { spec, name, abs }
18
+ const namespaces = new Map() // `import * as x` → spec
19
+ for (const st of sf.statements) {
20
+ if (!ts.isImportDeclaration(st) || !st.importClause || !ts.isStringLiteral(st.moduleSpecifier))
21
+ continue
22
+ const spec = st.moduleSpecifier.text
23
+ const target = resolveImport(abs, spec)
24
+ if (st.importClause.name)
25
+ imports.set(st.importClause.name.text, { spec, name: 'default', abs: target })
26
+ const nb = st.importClause.namedBindings
27
+ if (nb && ts.isNamespaceImport(nb)) namespaces.set(nb.name.text, { spec, abs: target })
28
+ if (nb && ts.isNamedImports(nb))
29
+ for (const el of nb.elements)
30
+ imports.set(el.name.text, { spec, name: (el.propertyName ?? el.name).text, abs: target })
31
+ }
32
+ const helperAsserting = new Set()
33
+ const helperCache = new Map()
34
+ for (const [binding, imp] of imports) {
35
+ if (!imp.abs || /\.(test|spec)\./.test(imp.abs)) continue
36
+ if (!helperCache.has(imp.abs)) {
37
+ let names = new Set()
38
+ try {
39
+ const src = readFileSync(imp.abs, 'utf8')
40
+ names = assertingFunctions(parse(src, imp.abs))
41
+ } catch {}
42
+ helperCache.set(imp.abs, names)
43
+ }
44
+ if (helperCache.get(imp.abs).has(imp.name)) helperAsserting.add(binding)
45
+ }
46
+ return { imports, namespaces, helperAsserting }
47
+ }
48
+
49
+ export function unitsOf(sf, abs, imports) {
50
+ const renders = new Set()
51
+ walk(sf, (n) => {
52
+ if (
53
+ ts.isCallExpression(n) &&
54
+ ts.isIdentifier(n.expression) &&
55
+ n.expression.text === 'render' &&
56
+ n.arguments[0]
57
+ ) {
58
+ const first = unwrap(n.arguments[0])
59
+ if (ts.isIdentifier(first)) renders.add(first.text)
60
+ walk(first, (j) => {
61
+ if (
62
+ (ts.isJsxOpeningElement(j) || ts.isJsxSelfClosingElement(j)) &&
63
+ ts.isIdentifier(j.tagName)
64
+ )
65
+ renders.add(j.tagName.text)
66
+ })
67
+ }
68
+ })
69
+ const isComponentTest = contains(
70
+ sf,
71
+ (n) =>
72
+ ts.isCallExpression(n) &&
73
+ ts.isIdentifier(n.expression) &&
74
+ ['render', 'mount', 'hydrate'].includes(n.expression.text),
75
+ )
76
+ const units = new Set()
77
+ const unitSpecs = new Set()
78
+ const specKey = (spec) => spec.replace(/(\/index)?\.[cm]?[jt]sx?$/, '')
79
+ for (const name of renders) {
80
+ const imp = imports.get(name)
81
+ if (imp?.abs) units.add(moduleKey(imp.abs))
82
+ if (imp && local(abs, imp.spec)) unitSpecs.add(specKey(imp.spec))
83
+ }
84
+ const own = resolveImport(
85
+ abs,
86
+ `./${relative(dirname(abs), abs).replace(/\.(test|spec)\.[cm]?[jt]sx?$/, '')}`,
87
+ )
88
+ if (own) units.add(moduleKey(own))
89
+ const isUnitSpec = (spec) => {
90
+ const t = resolveImport(abs, spec)
91
+ return t ? units.has(moduleKey(t)) : unitSpecs.has(specKey(spec))
92
+ }
93
+ const isWeftSpec = (spec) =>
94
+ spec === '@weft-js/core' || spec.startsWith('@weft-js/core/') || spec.startsWith('@weft-js/')
95
+ return { isComponentTest, isUnitSpec, isWeftSpec }
96
+ }
97
+
98
+ export function contextOf(sf, abs) {
99
+ const { imports, namespaces, helperAsserting } = importsOf(sf, abs)
100
+ const asserting = assertingFunctions(sf, helperAsserting)
101
+ const { isComponentTest, isUnitSpec, isWeftSpec } = unitsOf(sf, abs, imports)
102
+ const fakeTimers = contains(
103
+ sf,
104
+ (n) =>
105
+ ts.isPropertyAccessExpression(n) &&
106
+ (n.name.text === 'useFakeTimers' || (n.name.text === 'install' && rootName(n) === 'clock')),
107
+ )
108
+ return { imports, namespaces, asserting, isComponentTest, isUnitSpec, isWeftSpec, fakeTimers }
109
+ }
@@ -0,0 +1,279 @@
1
+ import ts from 'typescript'
2
+ import {
3
+ contains,
4
+ hasCall,
5
+ isLiteral,
6
+ isSubject,
7
+ matcherOf,
8
+ norm,
9
+ rootName,
10
+ unwrap,
11
+ walk,
12
+ } from './syntax.mjs'
13
+ import {
14
+ flushFix,
15
+ insideFakeApi,
16
+ isComparison,
17
+ readsInternalState,
18
+ sameValueMatchers,
19
+ sleepPromise,
20
+ } from './rule-helpers.mjs'
21
+
22
+ const snapshotMatchers = new Set([
23
+ 'toMatchSnapshot',
24
+ 'toMatchInlineSnapshot',
25
+ 'toMatchFileSnapshot',
26
+ 'toThrowErrorMatchingSnapshot',
27
+ 'toThrowErrorMatchingInlineSnapshot',
28
+ ])
29
+ const domQueries = new Set([
30
+ 'querySelector',
31
+ 'querySelectorAll',
32
+ 'getElementsByClassName',
33
+ 'getElementsByTagName',
34
+ 'getElementById',
35
+ 'closest',
36
+ ])
37
+ const timerCalls = new Set(['setTimeout', 'setInterval', 'requestAnimationFrame', 'setImmediate'])
38
+
39
+ export function renderResultsOf(sf, report) {
40
+ const renderResults = new Set()
41
+ walk(sf, (n) => {
42
+ if (
43
+ ts.isVariableDeclaration(n) &&
44
+ n.initializer &&
45
+ ts.isCallExpression(unwrap(n.initializer)) &&
46
+ rootName(unwrap(n.initializer).expression) === 'render'
47
+ ) {
48
+ if (ts.isIdentifier(n.name)) renderResults.add(n.name.text)
49
+ if (ts.isObjectBindingPattern(n.name))
50
+ for (const el of n.name.elements)
51
+ if ((el.propertyName ?? el.name).getText() === 'container')
52
+ report('T005', el, '`container` reaches into the DOM tree the user never sees.')
53
+ }
54
+ })
55
+ return renderResults
56
+ }
57
+
58
+ function subjectRule(n, { report }) {
59
+ if (isSubject(n)) {
60
+ const m = matcherOf(n)
61
+ const subject = n.arguments[0]
62
+ if (m && subject) {
63
+ if (isLiteral(subject))
64
+ report('T002', n, `\`${norm(n)}\` checks a literal, so it can never fail.`)
65
+ else if (
66
+ sameValueMatchers.has(m.name) &&
67
+ !m.not &&
68
+ m.call.arguments[0] &&
69
+ norm(m.call.arguments[0]) === norm(subject) &&
70
+ !hasCall(subject)
71
+ )
72
+ report(
73
+ 'T002',
74
+ m.call,
75
+ `\`${norm(m.call)}\` compares \`${norm(subject)}\` with itself, so it can never fail.`,
76
+ )
77
+ else if (
78
+ ts.isBinaryExpression(unwrap(subject)) &&
79
+ isComparison(unwrap(subject)) &&
80
+ norm(unwrap(subject).left) === norm(unwrap(subject).right) &&
81
+ !hasCall(subject)
82
+ )
83
+ report(
84
+ 'T002',
85
+ n,
86
+ `\`${norm(subject)}\` compares a value with itself, so it can never fail.`,
87
+ )
88
+ }
89
+ }
90
+ }
91
+
92
+ function snapshotRule(n, { report }) {
93
+ if (!isSubject(n)) return
94
+ const m = matcherOf(n)
95
+ const subject = n.arguments[0]
96
+ if (!m || !subject) return
97
+ if (snapshotMatchers.has(m.name))
98
+ report(
99
+ 'T007',
100
+ m.node,
101
+ `\`${m.name}\` records whatever the code does today and passes against it: it pins output, not behavior.`,
102
+ )
103
+ }
104
+
105
+ function subjectDomRule(n, { report, isComponentTest, imports }) {
106
+ if (!isSubject(n)) return
107
+ const m = matcherOf(n)
108
+ const subject = n.arguments[0]
109
+ if (!m || !subject) return
110
+ if (m.name === 'toHaveClass')
111
+ report('T005', m.node, '`toHaveClass` asserts on styling classes, not on what the user sees.')
112
+ if (
113
+ contains(
114
+ subject,
115
+ (x) =>
116
+ ts.isPropertyAccessExpression(x) &&
117
+ ['className', 'classList', 'innerHTML', 'outerHTML'].includes(x.name.text),
118
+ )
119
+ ) {
120
+ const prop = []
121
+ walk(subject, (x) => {
122
+ if (
123
+ ts.isPropertyAccessExpression(x) &&
124
+ ['className', 'classList', 'innerHTML', 'outerHTML'].includes(x.name.text)
125
+ )
126
+ prop.push(x.name.text)
127
+ })
128
+ report(
129
+ 'T005',
130
+ subject,
131
+ `Asserting on \`${prop[0]}\` pins markup and classes, not what the user sees.`,
132
+ )
133
+ }
134
+ if (isComponentTest && readsInternalState(subject, imports))
135
+ report(
136
+ 'T005',
137
+ subject,
138
+ `\`${norm(subject)}\` reads the component's state directly instead of what it renders.`,
139
+ )
140
+ }
141
+
142
+ function queryRule(n, { report }, c, name) {
143
+ if (name && domQueries.has(name) && ts.isPropertyAccessExpression(c))
144
+ report('T005', c.name, `\`${name}\` queries by CSS structure, which users never see.`)
145
+ if (name && /^(get|query|find)(All)?ByTestId$/.test(name))
146
+ report(
147
+ 'T005',
148
+ ts.isPropertyAccessExpression(c) ? c.name : c,
149
+ `\`${name}\` finds the element by a test id the user never sees.`,
150
+ )
151
+ }
152
+
153
+ function timerRule(n, { sf, edit, report, fakeTimers }, c, name) {
154
+ if (name === 'waitFor' || name === 'waitForElementToBeRemoved')
155
+ report('T004', c, `\`${name}\` polls on real timers and guesses how long the update takes.`, {
156
+ help: '`await user.click(…)` and `await flush()` settle every update; there is nothing to wait for',
157
+ })
158
+ if (ts.isIdentifier(c) && ['sleep', 'delay'].includes(c.text) && ts.isAwaitExpression(n.parent))
159
+ report(
160
+ 'T004',
161
+ n,
162
+ `\`${norm(n)}\` sleeps on the real clock: slow, and flaky when the machine is busy.`,
163
+ )
164
+ if (
165
+ name &&
166
+ timerCalls.has(name) &&
167
+ (ts.isIdentifier(c) || ['window', 'globalThis', 'global'].includes(rootName(c)))
168
+ ) {
169
+ const sleep = sleepPromise(n)
170
+ if (sleep) {
171
+ const delay = n.arguments[1]
172
+ const zero = !delay || (ts.isNumericLiteral(delay) && Number(delay.text) === 0)
173
+ const fix = zero ? flushFix(sf, sleep, edit) : undefined
174
+ report(
175
+ 'T004',
176
+ sleep,
177
+ `\`${norm(sleep)}\` sleeps on the real clock: slow, and flaky when the machine is busy.`,
178
+ {
179
+ help: zero
180
+ ? 'Use `await flush()`: it settles every pending update'
181
+ : `Install the fake clock (\`clock.install()\`) and use \`await clock.advance(${delay.getText()})\``,
182
+ fix,
183
+ },
184
+ )
185
+ } else if (!fakeTimers && !insideFakeApi(n))
186
+ report('T004', c, `\`${name}\` runs on the real clock in a test.`, {
187
+ help: 'Call `clock.install()` from @weft-js/core/test first and drive time with `await clock.advance(ms)`',
188
+ })
189
+ }
190
+ }
191
+
192
+ function mockRule(n, { report, namespaces, isWeftSpec, isUnitSpec }, c, name) {
193
+ if (
194
+ name &&
195
+ ['mock', 'doMock'].includes(name) &&
196
+ ['vi', 'jest'].includes(rootName(c)) &&
197
+ n.arguments[0] &&
198
+ ts.isStringLiteralLike(n.arguments[0])
199
+ ) {
200
+ const spec = n.arguments[0].text
201
+ if (isWeftSpec(spec))
202
+ report(
203
+ 'T006',
204
+ n,
205
+ `Mocking \`${spec}\` replaces the framework the test is supposed to exercise.`,
206
+ )
207
+ else if (isUnitSpec(spec))
208
+ report(
209
+ 'T006',
210
+ n,
211
+ `Mocking \`${spec}\` replaces the unit under test, so the test checks the mock.`,
212
+ )
213
+ }
214
+ if (
215
+ name === 'spyOn' &&
216
+ ['vi', 'jest'].includes(rootName(c)) &&
217
+ n.arguments[0] &&
218
+ ts.isIdentifier(n.arguments[0])
219
+ ) {
220
+ const ns = namespaces.get(n.arguments[0].text)
221
+ if (ns && isWeftSpec(ns.spec))
222
+ report(
223
+ 'T006',
224
+ n,
225
+ `Spying on \`${ns.spec}\` replaces the framework the test is supposed to exercise.`,
226
+ )
227
+ else if (ns && isUnitSpec(ns.spec))
228
+ report(
229
+ 'T006',
230
+ n,
231
+ `Spying on \`${ns.spec}\` replaces the unit under test, so the test checks the mock.`,
232
+ )
233
+ }
234
+ }
235
+
236
+ function domRule(n, ctx, c, name) {
237
+ subjectDomRule(n, ctx)
238
+ queryRule(n, ctx, c, name)
239
+ }
240
+
241
+ const callRules = [subjectRule, snapshotRule, domRule, timerRule, mockRule]
242
+
243
+ function callRule(n, ctx) {
244
+ if (!ts.isCallExpression(n)) return
245
+ const c = n.expression
246
+ const name = ts.isIdentifier(c)
247
+ ? c.text
248
+ : ts.isPropertyAccessExpression(c)
249
+ ? c.name.text
250
+ : undefined
251
+ for (const rule of callRules) rule(n, ctx, c, name)
252
+ }
253
+
254
+ function socketRule(n, { report }) {
255
+ if (
256
+ ts.isNewExpression(n) &&
257
+ ts.isIdentifier(n.expression) &&
258
+ ['WebSocket', 'EventSource'].includes(n.expression.text)
259
+ )
260
+ report('T013', n, `\`new ${n.expression.text}\` opens a real socket in a fast test.`, {
261
+ help: "Render the `.live()` or stream against @weft-js/core/test's fake transport: `await net.push(stream, message)`, `await net.poke('tag')`, `await net.drop()`",
262
+ })
263
+ }
264
+
265
+ function containerRule(n, { report, renderResults }) {
266
+ if (
267
+ ts.isPropertyAccessExpression(n) &&
268
+ n.name.text === 'container' &&
269
+ !(ts.isCallExpression(n.parent) && n.parent.expression === n)
270
+ ) {
271
+ const obj = unwrap(n.expression)
272
+ const fromRender =
273
+ (ts.isCallExpression(obj) && rootName(obj.expression) === 'render') ||
274
+ (ts.isIdentifier(obj) && renderResults.has(obj.text))
275
+ if (fromRender) report('T005', n, '`container` reaches into the DOM tree the user never sees.')
276
+ }
277
+ }
278
+
279
+ export const fileRules = [callRule, socketRule, containerRule]
@@ -0,0 +1,244 @@
1
+ import ts from 'typescript'
2
+ import {
3
+ contains,
4
+ hasCall,
5
+ isFn,
6
+ isLiteral,
7
+ isSubject,
8
+ matcherOf,
9
+ norm,
10
+ unwrap,
11
+ } from './syntax.mjs'
12
+
13
+ export const sameValueMatchers = new Set(['toBe', 'toEqual', 'toStrictEqual'])
14
+ const loopCallbacks = new Set([
15
+ 'forEach',
16
+ 'map',
17
+ 'flatMap',
18
+ 'filter',
19
+ 'some',
20
+ 'every',
21
+ 'find',
22
+ 'findIndex',
23
+ 'reduce',
24
+ ])
25
+ export const isComparison = (b) =>
26
+ [
27
+ ts.SyntaxKind.EqualsEqualsEqualsToken,
28
+ ts.SyntaxKind.EqualsEqualsToken,
29
+ ts.SyntaxKind.ExclamationEqualsEqualsToken,
30
+ ts.SyntaxKind.ExclamationEqualsToken,
31
+ ].includes(b.operatorToken.kind)
32
+
33
+ /** Why `node`, under ancestor `a`, only runs sometimes (`loop`: maybe zero times); undefined when it always runs. */
34
+ export function conditional(a, node) {
35
+ const within = (x) => x && node.getStart() >= x.getStart() && node.getEnd() <= x.getEnd()
36
+ const when = (text) => ({ text })
37
+ const loop = (text) => ({ text, loop: true })
38
+ if (ts.isIfStatement(a) && !within(a.expression)) return when('when the `if` matches')
39
+ if (ts.isConditionalExpression(a) && !within(a.condition)) return when('on one side of a `?:`')
40
+ if (
41
+ ts.isBinaryExpression(a) &&
42
+ [
43
+ ts.SyntaxKind.AmpersandAmpersandToken,
44
+ ts.SyntaxKind.BarBarToken,
45
+ ts.SyntaxKind.QuestionQuestionToken,
46
+ ].includes(a.operatorToken.kind) &&
47
+ within(a.right)
48
+ )
49
+ return when(`after a \`${a.operatorToken.getText()}\``)
50
+ if (ts.isCaseClause(a) || ts.isDefaultClause(a)) return when('in one `switch` case')
51
+ if (ts.isCatchClause(a)) return when('when something throws')
52
+ if (
53
+ (ts.isForStatement(a) ||
54
+ ts.isForOfStatement(a) ||
55
+ ts.isForInStatement(a) ||
56
+ ts.isWhileStatement(a) ||
57
+ ts.isDoStatement(a)) &&
58
+ within(a.statement)
59
+ )
60
+ return loop('inside a loop, and zero iterations check nothing')
61
+ if (
62
+ isFn(a) &&
63
+ ts.isCallExpression(a.parent) &&
64
+ ts.isPropertyAccessExpression(a.parent.expression) &&
65
+ loopCallbacks.has(a.parent.expression.name.text) &&
66
+ a.parent.arguments.includes(a)
67
+ )
68
+ return loop(`inside \`.${a.parent.expression.name.text}\`, and an empty list checks nothing`)
69
+ return undefined
70
+ }
71
+
72
+ /** `expect(cart.items.value)` with `cart` imported from app code, in a component test. (`scope(cart)` from @weft-js/core/test is the way to test a scope.) */
73
+ export function readsInternalState(subject, imports) {
74
+ const s = unwrap(subject)
75
+ if (!ts.isPropertyAccessExpression(s) || s.name.text !== 'value') return false
76
+ let n = s.expression
77
+ for (;;) {
78
+ n = unwrap(n)
79
+ if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) n = n.expression
80
+ else break
81
+ }
82
+ return ts.isIdentifier(n) && Boolean(imports.get(n.text)?.abs)
83
+ }
84
+
85
+ /** `new Promise(r => setTimeout(r, ms))`: the timer call's enclosing sleep, or undefined. */
86
+ export function sleepPromise(timer) {
87
+ const cb = timer.arguments[0]
88
+ let exec = timer.parent
89
+ while (exec && (ts.isExpressionStatement(exec) || ts.isBlock(exec))) exec = exec.parent
90
+ if (
91
+ !isFn(exec) ||
92
+ !ts.isNewExpression(exec.parent) ||
93
+ exec.parent.expression.getText() !== 'Promise'
94
+ )
95
+ return undefined
96
+ const resolveName = exec.parameters[0]?.name.getText()
97
+ const callsResolve =
98
+ cb &&
99
+ (cb.getText() === resolveName ||
100
+ (isFn(cb) &&
101
+ contains(cb.body, (x) => ts.isCallExpression(x) && x.expression.getText() === resolveName)))
102
+ if (!callsResolve) return undefined
103
+ // A fake API's latency resolves with data (`ok(user)`); a sleep resolves with nothing.
104
+ if (
105
+ isFn(cb) &&
106
+ contains(
107
+ cb.body,
108
+ (x) =>
109
+ ts.isCallExpression(x) && x.expression.getText() === resolveName && x.arguments.length > 0,
110
+ )
111
+ )
112
+ return undefined
113
+ return exec.parent
114
+ }
115
+
116
+ /** Timers inside a function that a fake API returns (`load: id => new Promise(ok => setTimeout(() => ok(x), ms))`). */
117
+ export function insideFakeApi(timer) {
118
+ for (let n = timer.parent; n; n = n.parent) {
119
+ if (ts.isNewExpression(n) && n.expression.getText() === 'Promise') return true
120
+ }
121
+ return false
122
+ }
123
+
124
+ /** Replace a zero-delay sleep with `flush()`, importing it from @weft-js/core/test when the file doesn't. */
125
+ export function flushFix(sf, sleep, edit) {
126
+ const edits = [edit(sleep.getStart(sf), sleep.getEnd(), 'flush()')]
127
+ const hasFlush = sf.statements.some(
128
+ (st) =>
129
+ ts.isImportDeclaration(st) &&
130
+ st.importClause?.namedBindings &&
131
+ ts.isNamedImports(st.importClause.namedBindings) &&
132
+ st.importClause.namedBindings.elements.some((e) => e.name.text === 'flush'),
133
+ )
134
+ if (!hasFlush) {
135
+ const weftTest = sf.statements.find(
136
+ (st) =>
137
+ ts.isImportDeclaration(st) &&
138
+ ts.isStringLiteral(st.moduleSpecifier) &&
139
+ st.moduleSpecifier.text === '@weft-js/core/test' &&
140
+ st.importClause?.namedBindings &&
141
+ ts.isNamedImports(st.importClause.namedBindings),
142
+ )
143
+ if (weftTest) {
144
+ const el = weftTest.importClause.namedBindings.elements
145
+ edits.push(edit(el[el.length - 1].getEnd(), el[el.length - 1].getEnd(), ', flush'))
146
+ } else {
147
+ const last = [...sf.statements].reverse().find((st) => ts.isImportDeclaration(st))
148
+ const at = last ? last.getEnd() : 0
149
+ edits.push(
150
+ edit(
151
+ at,
152
+ at,
153
+ `${last ? '\n' : ''}import { flush } from '@weft-js/core/test'${last ? '' : '\n'}`,
154
+ ),
155
+ )
156
+ }
157
+ }
158
+ return { description: 'Replace the sleep with `flush()`', safe: false, edits }
159
+ }
160
+
161
+ /**
162
+ * T003 in one test body: `n.value = 5; expect(n.value).toBe(5)`, or `const s = state(3); expect(s.value).toBe(3)`.
163
+ * Any call between the write and the assertion (a click, a render, a fake push) may route through the app, so it ends
164
+ * the check; `flush()` doesn't.
165
+ */
166
+ export function assertsWhatItSet(statements, report) {
167
+ const writes = new Map()
168
+ const boxes = new Set() // `const n = state(…)`: a plain box, so a write reads straight back
169
+ const locals = new Set()
170
+ const isFlush = (st) => {
171
+ const e = ts.isExpressionStatement(st) ? unwrap(st.expression) : null
172
+ return e && ts.isCallExpression(e) && e.expression.getText() === 'flush'
173
+ }
174
+ for (const st of statements) {
175
+ if (ts.isVariableStatement(st)) {
176
+ for (const d of st.declarationList.declarations) {
177
+ if (!ts.isIdentifier(d.name) || !d.initializer) continue
178
+ const init = unwrap(d.initializer)
179
+ locals.add(d.name.text)
180
+ if (ts.isCallExpression(init) && init.expression.getText() === 'state')
181
+ boxes.add(d.name.text)
182
+ if (isLiteral(init)) writes.set(d.name.text, init)
183
+ else if (
184
+ ts.isCallExpression(init) &&
185
+ init.expression.getText() === 'state' &&
186
+ init.arguments[0] &&
187
+ isLiteral(init.arguments[0])
188
+ )
189
+ writes.set(`${d.name.text}.value`, init.arguments[0])
190
+ else if (hasCall(init)) writes.clear()
191
+ }
192
+ continue
193
+ }
194
+ if (isFlush(st)) continue
195
+ const e = ts.isExpressionStatement(st) ? unwrap(st.expression) : null
196
+ if (
197
+ e &&
198
+ ts.isBinaryExpression(e) &&
199
+ e.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
200
+ !hasCall(e.left)
201
+ ) {
202
+ const target = norm(e.left)
203
+ const plain =
204
+ (ts.isIdentifier(e.left) && locals.has(target)) ||
205
+ (ts.isPropertyAccessExpression(e.left) &&
206
+ e.left.name.text === 'value' &&
207
+ boxes.has(norm(e.left.expression)))
208
+ if (isLiteral(e.right) && plain) writes.set(target, e.right)
209
+ else writes.delete(target)
210
+ if (hasCall(e.right)) writes.clear()
211
+ continue
212
+ }
213
+ const subject = e && findSubject(e)
214
+ if (subject) {
215
+ const m = matcherOf(subject)
216
+ const written = subject.arguments[0] && writes.get(norm(subject.arguments[0]))
217
+ if (
218
+ m &&
219
+ !m.not &&
220
+ sameValueMatchers.has(m.name) &&
221
+ written &&
222
+ m.call.arguments[0] &&
223
+ norm(m.call.arguments[0]) === norm(written)
224
+ )
225
+ report(
226
+ 'T003',
227
+ m.call,
228
+ `The test set \`${norm(subject.arguments[0])}\` to \`${norm(written)}\` itself and asserts it back: nothing between went through the UI or the graph.`,
229
+ )
230
+ continue
231
+ }
232
+ writes.clear()
233
+ }
234
+ }
235
+
236
+ /** The subject call of a statement that is one assertion chain (`expect(a).toBe(b)`), else undefined. */
237
+ function findSubject(e) {
238
+ let n = unwrap(e)
239
+ for (;;) {
240
+ if (ts.isCallExpression(n) && isSubject(n)) return n
241
+ if (ts.isCallExpression(n) || ts.isPropertyAccessExpression(n)) n = unwrap(n.expression)
242
+ else return undefined
243
+ }
244
+ }