@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,223 @@
1
+ import { existsSync } from 'node:fs'
2
+ import ts from 'typescript'
3
+ import { local } from '../alias.mjs'
4
+
5
+ const suiteRoots = new Set(['describe', 'suite', 'fdescribe', 'xdescribe'])
6
+ const testRoots = new Set(['test', 'it', 'fit', 'xit', 'xtest', ...suiteRoots])
7
+ const exts = [
8
+ '',
9
+ '.ts',
10
+ '.tsx',
11
+ '.mts',
12
+ '.js',
13
+ '.jsx',
14
+ '.mjs',
15
+ '/index.ts',
16
+ '/index.tsx',
17
+ '/index.js',
18
+ ]
19
+
20
+ const kindOf = (file) =>
21
+ file.endsWith('.tsx')
22
+ ? ts.ScriptKind.TSX
23
+ : file.endsWith('.jsx')
24
+ ? ts.ScriptKind.JSX
25
+ : /\.[mc]?ts$/.test(file)
26
+ ? ts.ScriptKind.TS
27
+ : ts.ScriptKind.JS
28
+ export const parse = (text, file) =>
29
+ ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kindOf(file))
30
+ export const isFn = (n) =>
31
+ n && (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n))
32
+ export const norm = (n) => n.getText().replace(/\s+/g, ' ').replace(/;$/, '')
33
+ export const unwrap = (n) => {
34
+ while (
35
+ n &&
36
+ (ts.isParenthesizedExpression(n) ||
37
+ ts.isAsExpression(n) ||
38
+ ts.isNonNullExpression(n) ||
39
+ ts.isAwaitExpression(n))
40
+ )
41
+ n = n.expression
42
+ return n
43
+ }
44
+
45
+ export function walk(node, visit) {
46
+ const go = (n) => {
47
+ if (visit(n) === false) return
48
+ ts.forEachChild(n, go)
49
+ }
50
+ go(node)
51
+ }
52
+
53
+ export const contains = (node, pred) => {
54
+ let hit = false
55
+ walk(node, (n) => {
56
+ if (hit) return false
57
+ if (pred(n)) hit = true
58
+ })
59
+ return hit
60
+ }
61
+
62
+ /** The identifier at the root of `a.b(…).c`, or undefined. */
63
+ export function rootName(expr) {
64
+ let n = expr
65
+ for (;;) {
66
+ n = unwrap(n)
67
+ if (
68
+ ts.isPropertyAccessExpression(n) ||
69
+ ts.isElementAccessExpression(n) ||
70
+ ts.isCallExpression(n)
71
+ )
72
+ n = n.expression
73
+ else break
74
+ }
75
+ return ts.isIdentifier(n) ? n.text : undefined
76
+ }
77
+
78
+ /** `1`, `'a'`, `null`, `[1, { a: 'b' }]`: a value with nothing behind it. */
79
+ export function isLiteral(n) {
80
+ n = unwrap(n)
81
+ if (!n) return false
82
+ if (
83
+ ts.isStringLiteral(n) ||
84
+ ts.isNumericLiteral(n) ||
85
+ ts.isBigIntLiteral(n) ||
86
+ ts.isNoSubstitutionTemplateLiteral(n) ||
87
+ ts.isRegularExpressionLiteral(n)
88
+ )
89
+ return true
90
+ if (
91
+ [ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(
92
+ n.kind,
93
+ )
94
+ )
95
+ return true
96
+ if (ts.isIdentifier(n)) return n.text === 'undefined' || n.text === 'NaN' || n.text === 'Infinity'
97
+ if (ts.isPrefixUnaryExpression(n)) return isLiteral(n.operand)
98
+ if (ts.isArrayLiteralExpression(n)) return n.elements.every(isLiteral)
99
+ if (ts.isObjectLiteralExpression(n))
100
+ return n.properties.every((p) => ts.isPropertyAssignment(p) && isLiteral(p.initializer))
101
+ return false
102
+ }
103
+
104
+ export const hasCall = (n) =>
105
+ contains(
106
+ n,
107
+ (x) =>
108
+ ts.isCallExpression(x) ||
109
+ ts.isNewExpression(x) ||
110
+ ts.isAwaitExpression(x) ||
111
+ ts.isTaggedTemplateExpression(x),
112
+ )
113
+
114
+ /** `expect(x)`, `expect.soft(x)`, `t.expect(x)`: the call that takes the subject. */
115
+ export function isSubject(call) {
116
+ const c = call.expression
117
+ if (ts.isIdentifier(c)) return c.text === 'expect'
118
+ if (!ts.isPropertyAccessExpression(c)) return false
119
+ if (c.name.text === 'expect') return true
120
+ return (
121
+ ts.isIdentifier(c.expression) &&
122
+ c.expression.text === 'expect' &&
123
+ ['soft', 'poll'].includes(c.name.text)
124
+ )
125
+ }
126
+
127
+ /** For a subject call, the matcher it ends in: `expect(x).not.toBe(1)` → { name: 'toBe', call, not: true }. */
128
+ export function matcherOf(subject) {
129
+ let cur = subject
130
+ let not = false
131
+ const mods = []
132
+ for (;;) {
133
+ const p = cur.parent
134
+ if (p && ts.isPropertyAccessExpression(p) && p.expression === cur) {
135
+ const name = p.name.text
136
+ if (p.parent && ts.isCallExpression(p.parent) && p.parent.expression === p)
137
+ return { name, node: p.name, call: p.parent, not, mods }
138
+ if (name === 'not') not = !not
139
+ mods.push(name)
140
+ cur = p
141
+ } else return null
142
+ }
143
+ }
144
+
145
+ /** Test and suite calls, with their modifiers: `test.only.each(table)('x', fn)`. */
146
+ export function testCall(call) {
147
+ // The outer call of a curried form (`test.each(t)(…)`) is the test; skip the inner one.
148
+ if (call.parent && ts.isCallExpression(call.parent) && call.parent.expression === call)
149
+ return null
150
+ const mods = []
151
+ let n = call.expression
152
+ for (;;) {
153
+ if (ts.isPropertyAccessExpression(n)) {
154
+ mods.unshift({ name: n.name.text, node: n })
155
+ n = n.expression
156
+ } else if (ts.isCallExpression(n)) n = n.expression
157
+ else break
158
+ }
159
+ if (!ts.isIdentifier(n) || !testRoots.has(n.text)) return null
160
+ const root = n.text
161
+ const fn = [...call.arguments]
162
+ .reverse()
163
+ .find((a) => ts.isArrowFunction(a) || ts.isFunctionExpression(a))
164
+ // `fit` with no test body is someone's function (@weft-js/core/text's), not a focused test
165
+ if (root === 'fit' && !fn) return null
166
+ return { root, rootNode: n, mods, fn, suite: suiteRoots.has(root), call }
167
+ }
168
+
169
+ /** Functions (by name) whose bodies assert, in one file: local helpers and, given `imported`, what they call. */
170
+ export function assertingFunctions(sf, imported = new Set()) {
171
+ const bodies = new Map()
172
+ const add = (name, fn) => fn?.body && bodies.set(name, fn.body)
173
+ for (const st of sf.statements) {
174
+ const decl = ts.isExportDeclaration(st) ? null : st
175
+ if (decl && ts.isFunctionDeclaration(decl) && decl.name) add(decl.name.text, decl)
176
+ if (decl && ts.isVariableStatement(decl))
177
+ for (const d of decl.declarationList.declarations)
178
+ if (ts.isIdentifier(d.name) && isFn(d.initializer)) add(d.name.text, d.initializer)
179
+ }
180
+ const asserting = new Set(imported)
181
+ for (let changed = true; changed;) {
182
+ changed = false
183
+ for (const [name, body] of bodies) {
184
+ if (asserting.has(name) || !asserts(body, asserting)) continue
185
+ asserting.add(name)
186
+ changed = true
187
+ }
188
+ }
189
+ return asserting
190
+ }
191
+
192
+ /** Does this code assert? A matcher, `assert…`, `expect.assertions`, a helper named `expect…`, or a known asserting function. */
193
+ export function asserts(node, asserting) {
194
+ return contains(node, (n) => {
195
+ if (!ts.isCallExpression(n)) return false
196
+ if (isSubject(n)) return Boolean(matcherOf(n))
197
+ const c = n.expression
198
+ if (
199
+ ts.isPropertyAccessExpression(c) &&
200
+ ts.isIdentifier(c.expression) &&
201
+ c.expression.text === 'expect'
202
+ )
203
+ return true // expect.assertions(n)
204
+ const name = ts.isIdentifier(c)
205
+ ? c.text
206
+ : ts.isPropertyAccessExpression(c) && ts.isIdentifier(c.expression)
207
+ ? c.expression.text
208
+ : undefined
209
+ return Boolean(name && (/^(expect|assert)([A-Z_]|$)/.test(name) || asserting.has(name)))
210
+ })
211
+ }
212
+
213
+ export function resolveImport(fromAbs, spec) {
214
+ const base = local(fromAbs, spec)
215
+ if (!base) return null
216
+ for (const e of exts) {
217
+ const p = base + e
218
+ if (existsSync(p) && !p.endsWith('/') && (e || /\.[cm]?[jt]sx?$/.test(p))) return p
219
+ }
220
+ return null
221
+ }
222
+
223
+ export const moduleKey = (abs) => abs.replace(/(\/index)?\.[cm]?[jt]sx?$/, '')
@@ -0,0 +1,127 @@
1
+ import ts from 'typescript'
2
+ import { asserts, isSubject, matcherOf, norm, walk } from './syntax.mjs'
3
+ import { assertsWhatItSet, conditional } from './rule-helpers.mjs'
4
+
5
+ const focusRoots = { fit: 'it', fdescribe: 'describe' }
6
+ const skipRoots = { xit: 'it', xtest: 'test', xdescribe: 'describe' }
7
+
8
+ export function focusRule(t, { sf, edit, report }) {
9
+ const focus =
10
+ t.mods.find((m) => m.name === 'only') ??
11
+ (focusRoots[t.root] && { name: 'only', node: t.rootNode })
12
+ const skip =
13
+ t.mods.find((m) => m.name === 'skip') ??
14
+ (skipRoots[t.root] && { name: 'skip', node: t.rootNode })
15
+ for (const m of [focus, skip].filter(Boolean)) {
16
+ const kind = t.suite ? 'suite' : 'test'
17
+ let fix
18
+ if (m.node === t.rootNode) {
19
+ const to = focusRoots[t.root] ?? skipRoots[t.root]
20
+ fix = {
21
+ description: `Rename \`${t.root}\` to \`${to}\``,
22
+ safe: m.name === 'only',
23
+ edits: [edit(m.node.getStart(sf), m.node.getEnd(), to)],
24
+ }
25
+ report(
26
+ 'T009',
27
+ m.node,
28
+ `\`${t.root}\` ${m.name === 'only' ? 'focuses this ' + kind + ': every other test in the run is silently skipped' : 'skips this ' + kind + ': it never runs, so it proves nothing'}.`,
29
+ { fix },
30
+ )
31
+ } else {
32
+ const dot = m.node.expression.getEnd()
33
+ fix = {
34
+ description: `Remove \`.${m.name}\``,
35
+ safe: m.name === 'only',
36
+ edits: [edit(dot, m.node.getEnd(), '')],
37
+ }
38
+ report(
39
+ 'T009',
40
+ m.node.name,
41
+ m.name === 'only'
42
+ ? `\`.only\` focuses this ${kind}: every other test in the run is silently skipped.`
43
+ : `\`.skip\` turns this ${kind} off: it never runs, so it proves nothing. Fix or delete it; a test that is meant to fail is \`test.fails\`.`,
44
+ { fix },
45
+ )
46
+ }
47
+ }
48
+ }
49
+
50
+ function assertionRule(t, { asserting, report }, n, previouslyNoAssertion) {
51
+ if (n) {
52
+ if (!previouslyNoAssertion)
53
+ report('T001', n, `\`${norm(n)}\` has no matcher, so it asserts nothing.`, {
54
+ help: 'Finish it: `expect(x).toBe(…)`, `.toHaveTextContent(…)`, …',
55
+ })
56
+ return
57
+ }
58
+ const body = t.fn.body
59
+ const noAssertion = !asserts(body, asserting)
60
+ if (noAssertion) {
61
+ const title =
62
+ t.call.arguments[0] && ts.isStringLiteralLike(t.call.arguments[0])
63
+ ? ` "${t.call.arguments[0].text}"`
64
+ : ''
65
+ report(
66
+ 'T001',
67
+ t.call.expression,
68
+ `Test${title} makes no assertion: it passes as long as nothing throws.`,
69
+ )
70
+ }
71
+
72
+ return noAssertion
73
+ }
74
+
75
+ function conditionalRule(t, ctx, noAssertion) {
76
+ const { report } = ctx
77
+ const body = t.fn.body
78
+ // A looped `expect` is fine next to one that always runs; a conditional one never is.
79
+ const looped = []
80
+ let always = false
81
+ walk(body, (n) => {
82
+ if (!ts.isCallExpression(n) || !isSubject(n)) return
83
+ const m = matcherOf(n)
84
+ if (!m) {
85
+ assertionRule(t, ctx, n, noAssertion)
86
+ return
87
+ }
88
+ let why
89
+ for (let a = n.parent; a && a !== t.fn && !why; a = a.parent) why = conditional(a, n)
90
+ if (!why) always = true
91
+ else if (why.loop) looped.push([m.call, why.text])
92
+ else
93
+ report(
94
+ 'T008',
95
+ m.call,
96
+ `This \`expect\` only runs ${why.text}, so the test can pass without checking anything.`,
97
+ )
98
+ })
99
+ if (!always)
100
+ for (const [call, how] of looped)
101
+ report(
102
+ 'T008',
103
+ call,
104
+ `Every \`expect\` in this test runs ${how}, so the test can pass without checking anything.`,
105
+ )
106
+ }
107
+
108
+ export function setRule(t, { report }) {
109
+ const body = t.fn.body
110
+ if (ts.isBlock(body)) assertsWhatItSet(body.statements, report)
111
+ }
112
+
113
+ export const testRules = [focusRule, assertionRule, conditionalRule, setRule]
114
+
115
+ export function runTestRules(t, ctx) {
116
+ testRules[0](t, ctx)
117
+ if (
118
+ t.suite ||
119
+ !t.fn ||
120
+ t.mods.some((m) => ['todo', 'skip'].includes(m.name)) ||
121
+ skipRoots[t.root]
122
+ )
123
+ return
124
+ const noAssertion = testRules[1](t, ctx)
125
+ testRules[2](t, ctx, noAssertion)
126
+ testRules[3](t, ctx)
127
+ }
package/src/testdiff.mjs CHANGED
@@ -11,11 +11,12 @@ const skipMods = new Set(['skip', 'todo'])
11
11
  const skipRoots = new Set(['xit', 'xtest', 'xdescribe'])
12
12
 
13
13
  /** A case's name as vitest reports it: suite titles and the test title joined by ' > '. */
14
- const join = names => names.join(' > ')
14
+ const join = (names) => names.join(' > ')
15
15
 
16
16
  function nameOf(arg) {
17
17
  if (!arg) return { name: '<anonymous>', dynamic: true }
18
- if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) return { name: arg.text, dynamic: false }
18
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg))
19
+ return { name: arg.text, dynamic: false }
19
20
  return { name: arg.getText(), dynamic: true }
20
21
  }
21
22
 
@@ -31,51 +32,74 @@ export function testCases(text, rel) {
31
32
  if (ts.isCallExpression(node)) {
32
33
  const t = testCall(node)
33
34
  if (t) {
34
- const each = t.mods.some(m => m.name === 'each' || m.name === 'for')
35
+ const each = t.mods.some((m) => m.name === 'each' || m.name === 'for')
35
36
  const { name, dynamic: dyn } = nameOf(node.arguments[0])
36
- const skip = skipped || skipRoots.has(t.root) || t.mods.some(m => skipMods.has(m.name))
37
+ const skip = skipped || skipRoots.has(t.root) || t.mods.some((m) => skipMods.has(m.name))
37
38
  const isDynamic = dynamic || dyn || each
38
39
  if (t.suite) {
39
- if (t.fn?.body) ts.forEachChild(t.fn.body, c => visit(c, [...suites, name], skip, isDynamic))
40
+ if (t.fn?.body)
41
+ ts.forEachChild(t.fn.body, (c) => visit(c, [...suites, name], skip, isDynamic))
40
42
  return
41
43
  }
42
44
  const asserts = []
43
45
  let expected = null
44
46
  if (t.fn?.body)
45
- walk(t.fn.body, n => {
47
+ walk(t.fn.body, (n) => {
46
48
  if (!ts.isCallExpression(n)) return
47
49
  if (isSubject(n)) {
48
50
  const m = matcherOf(n)
49
51
  if (!m) return
50
52
  const subject = n.arguments[0] ? norm(n.arguments[0]) : ''
51
53
  const args = m.call.arguments.map(norm).join(', ')
52
- asserts.push({ key: `${subject}|${m.not ? 'not.' : ''}${m.name}|${args}`, subject, matcher: m.name, not: m.not, args, line: pos.lineCol(n.getStart(sf))[0] })
53
- } else if (ts.isPropertyAccessExpression(n.expression) && ts.isIdentifier(n.expression.expression) && n.expression.expression.text === 'expect' && n.expression.name.text === 'assertions') {
54
+ asserts.push({
55
+ key: `${subject}|${m.not ? 'not.' : ''}${m.name}|${args}`,
56
+ subject,
57
+ matcher: m.name,
58
+ not: m.not,
59
+ args,
60
+ line: pos.lineCol(n.getStart(sf))[0],
61
+ })
62
+ } else if (
63
+ ts.isPropertyAccessExpression(n.expression) &&
64
+ ts.isIdentifier(n.expression.expression) &&
65
+ n.expression.expression.text === 'expect' &&
66
+ n.expression.name.text === 'assertions'
67
+ ) {
54
68
  expected = Number(n.arguments[0]?.getText()) || null
55
69
  }
56
70
  })
57
71
  out.push({
58
- name: join([...suites, name]), line: pos.lineCol(node.getStart(sf))[0], end: pos.lineCol(node.getEnd())[0], body: t.fn?.body ? norm(t.fn.body) : '',
59
- skipped: skip, fails: t.mods.some(m => m.name === 'fails'), dynamic: isDynamic, asserts, expected,
72
+ name: join([...suites, name]),
73
+ line: pos.lineCol(node.getStart(sf))[0],
74
+ end: pos.lineCol(node.getEnd())[0],
75
+ body: t.fn?.body ? norm(t.fn.body) : '',
76
+ skipped: skip,
77
+ fails: t.mods.some((m) => m.name === 'fails'),
78
+ dynamic: isDynamic,
79
+ asserts,
80
+ expected,
60
81
  })
61
82
  return
62
83
  }
63
84
  }
64
- ts.forEachChild(node, c => visit(c, suites, skipped, dynamic))
85
+ ts.forEachChild(node, (c) => visit(c, suites, skipped, dynamic))
65
86
  }
66
87
  visit(sf, [], false, false)
67
88
  return out
68
89
  }
69
90
 
70
91
  function walk(node, fn) {
71
- const go = n => { fn(n); ts.forEachChild(n, go) }
92
+ const go = (n) => {
93
+ fn(n)
94
+ ts.forEachChild(n, go)
95
+ }
72
96
  go(node)
73
97
  }
74
98
 
75
99
  /** The test names in `after` that are new or whose body changed since `before` (null: the file is new). */
76
100
  export function changedCases(before, after, rel) {
77
- const old = new Map((before == null ? [] : testCases(before, rel)).map(c => [c.name, c]))
78
- return testCases(after, rel).filter(c => !c.skipped && old.get(c.name)?.body !== c.body)
101
+ const old = new Map((before == null ? [] : testCases(before, rel)).map((c) => [c.name, c]))
102
+ return testCases(after, rel).filter((c) => !c.skipped && old.get(c.name)?.body !== c.body)
79
103
  }
80
104
 
81
105
  /**
@@ -101,7 +125,8 @@ const looser = {
101
125
 
102
126
  function loosened(a, b) {
103
127
  if (a.subject !== b.subject || a.not !== b.not) return false
104
- if (a.matcher === 'toThrow' || a.matcher === 'toThrowError') return b.matcher.startsWith('toThrow') && a.args && !b.args
128
+ if (a.matcher === 'toThrow' || a.matcher === 'toThrowError')
129
+ return b.matcher.startsWith('toThrow') && a.args && !b.args
105
130
  return looser[a.matcher]?.includes(b.matcher) ?? false
106
131
  }
107
132
 
@@ -113,16 +138,30 @@ export function weakened(before, after, rel) {
113
138
  if (before == null) return []
114
139
  const old = testCases(before, rel)
115
140
  if (after == null) {
116
- const live = old.filter(c => !c.skipped)
117
- return live.length ? [t020(rel, null, 1, `${rel} was deleted with ${live.length} test${live.length > 1 ? 's' : ''} in it (${live.slice(0, 3).map(c => `"${c.name}"`).join(', ')}${live.length > 3 ? ', …' : ''})`)] : []
141
+ const live = old.filter((c) => !c.skipped)
142
+ return live.length
143
+ ? [
144
+ t020(
145
+ rel,
146
+ null,
147
+ 1,
148
+ `${rel} was deleted with ${live.length} test${live.length > 1 ? 's' : ''} in it (${live
149
+ .slice(0, 3)
150
+ .map((c) => `"${c.name}"`)
151
+ .join(', ')}${live.length > 3 ? ', …' : ''})`,
152
+ ),
153
+ ]
154
+ : []
118
155
  }
119
156
  const sf = parse(after, rel)
120
157
  const allowed = allowances(after, sf)
121
158
  const now = testCases(after, rel)
122
- const byName = new Map(now.map(c => [c.name, c]))
123
- const byBody = new Set(now.map(c => c.body))
159
+ const byName = new Map(now.map((c) => [c.name, c]))
160
+ const byBody = new Set(now.map((c) => c.body))
124
161
  const out = []
125
- const report = (line, why) => { if (!allowed('T020', line)) out.push(t020(rel, after, line, why)) }
162
+ const report = (line, why) => {
163
+ if (!allowed('T020', line)) out.push(t020(rel, after, line, why))
164
+ }
126
165
  for (const o of old) {
127
166
  if (o.skipped || o.dynamic) continue
128
167
  const n = byName.get(o.name)
@@ -130,20 +169,40 @@ export function weakened(before, after, rel) {
130
169
  if (!byBody.has(o.body)) report(1, `test "${o.name}" was deleted`)
131
170
  continue
132
171
  }
133
- if (n.skipped) { report(n.line, `test "${o.name}" is now skipped`); continue }
134
- if (n.fails && !o.fails) { report(n.line, `test "${o.name}" is now marked \`.fails\``); continue }
135
- if (o.expected && (n.expected ?? 0) < o.expected) report(n.line, `test "${o.name}" now expects ${n.expected ?? 'no'} assertions instead of ${o.expected}`)
136
- const nowKeys = new Set(n.asserts.map(a => a.key))
137
- const oldKeys = new Set(o.asserts.map(a => a.key))
138
- const gone = o.asserts.filter(a => !nowKeys.has(a.key))
139
- const added = n.asserts.filter(a => !oldKeys.has(a.key))
172
+ if (n.skipped) {
173
+ report(n.line, `test "${o.name}" is now skipped`)
174
+ continue
175
+ }
176
+ if (n.fails && !o.fails) {
177
+ report(n.line, `test "${o.name}" is now marked \`.fails\``)
178
+ continue
179
+ }
180
+ if (o.expected && (n.expected ?? 0) < o.expected)
181
+ report(
182
+ n.line,
183
+ `test "${o.name}" now expects ${n.expected ?? 'no'} assertions instead of ${o.expected}`,
184
+ )
185
+ const nowKeys = new Set(n.asserts.map((a) => a.key))
186
+ const oldKeys = new Set(o.asserts.map((a) => a.key))
187
+ const gone = o.asserts.filter((a) => !nowKeys.has(a.key))
188
+ const added = n.asserts.filter((a) => !oldKeys.has(a.key))
140
189
  for (const a of gone) {
141
- const loose = added.find(b => loosened(a, b))
142
- if (loose) report(loose.line, `in "${o.name}", \`expect(${a.subject}).${a.not ? 'not.' : ''}${a.matcher}(${a.args})\` became \`${loose.not ? 'not.' : ''}${loose.matcher}(${loose.args})\`, which accepts more`)
190
+ const loose = added.find((b) => loosened(a, b))
191
+ if (loose)
192
+ report(
193
+ loose.line,
194
+ `in "${o.name}", \`expect(${a.subject}).${a.not ? 'not.' : ''}${a.matcher}(${a.args})\` became \`${loose.not ? 'not.' : ''}${loose.matcher}(${loose.args})\`, which accepts more`,
195
+ )
143
196
  }
144
- const replaced = gone.filter(a => !added.some(b => loosened(a, b)))
197
+ const replaced = gone.filter((a) => !added.some((b) => loosened(a, b)))
145
198
  if (n.asserts.length < o.asserts.length && replaced.length)
146
- report(n.line, `"${o.name}" lost ${o.asserts.length - n.asserts.length} assertion${o.asserts.length - n.asserts.length > 1 ? 's' : ''}: ${replaced.slice(0, 2).map(a => `\`expect(${a.subject}).${a.matcher}(…)\``).join(', ')}`)
199
+ report(
200
+ n.line,
201
+ `"${o.name}" lost ${o.asserts.length - n.asserts.length} assertion${o.asserts.length - n.asserts.length > 1 ? 's' : ''}: ${replaced
202
+ .slice(0, 2)
203
+ .map((a) => `\`expect(${a.subject}).${a.matcher}(…)\``)
204
+ .join(', ')}`,
205
+ )
147
206
  }
148
207
  return out
149
208
  }
@@ -151,7 +210,10 @@ export function weakened(before, after, rel) {
151
210
  function t020(rel, _text, line, why) {
152
211
  const span = [line, 1, line, 1]
153
212
  return diagnostic({
154
- code: 'T020', file: rel, span, why: `${why}. An existing test got weaker, so it may no longer catch what it used to.`,
213
+ code: 'T020',
214
+ file: rel,
215
+ span,
216
+ why: `${why}. An existing test got weaker, so it may no longer catch what it used to.`,
155
217
  help: 'Restore it; or, when the change really is intended (the old test pinned a bug), keep it with `// weft-allow T020: <reason>` on that test, where the reviewer sees it',
156
218
  })
157
219
  }