@weft-js/cli 0.0.0-stage → 0.1.1

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 (91) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +74 -2
  3. package/bin/weft.mjs +8 -0
  4. package/docs/async.md +40 -0
  5. package/docs/browser.md +39 -0
  6. package/docs/config.md +93 -0
  7. package/docs/deploy.md +83 -0
  8. package/docs/design.md +97 -0
  9. package/docs/fonts.md +70 -0
  10. package/docs/icons.md +102 -0
  11. package/docs/lists.md +23 -0
  12. package/docs/meta.md +82 -0
  13. package/docs/plugins.md +143 -0
  14. package/docs/props.md +77 -0
  15. package/docs/rules.md +32 -0
  16. package/docs/rust.md +223 -0
  17. package/docs/start-short.md +8 -0
  18. package/docs/start.md +422 -0
  19. package/docs/state.md +96 -0
  20. package/docs/style.md +59 -0
  21. package/docs/surface.md +111 -0
  22. package/docs/testing.md +71 -0
  23. package/docs/ui-state.md +92 -0
  24. package/docs/verify.md +48 -0
  25. package/package.json +39 -4
  26. package/src/add-rust.mjs +123 -0
  27. package/src/add.mjs +358 -0
  28. package/src/agents-md.mjs +134 -0
  29. package/src/alias.mjs +59 -0
  30. package/src/audit.mjs +366 -0
  31. package/src/browser/cli.mjs +34 -0
  32. package/src/browser/client.mjs +132 -0
  33. package/src/browser/commands.mjs +143 -0
  34. package/src/catalog.mjs +496 -0
  35. package/src/check.mjs +330 -0
  36. package/src/cli.mjs +364 -0
  37. package/src/config.d.ts +1 -0
  38. package/src/config.mjs +5 -0
  39. package/src/contributions.mjs +69 -0
  40. package/src/db-layer.mjs +33 -0
  41. package/src/deploy/cdn.mjs +64 -0
  42. package/src/deploy/files.mjs +187 -0
  43. package/src/deploy/fit.mjs +62 -0
  44. package/src/deploy/hosts.mjs +56 -0
  45. package/src/deploy/index.mjs +273 -0
  46. package/src/deploy/needs.mjs +92 -0
  47. package/src/design-checks.mjs +439 -0
  48. package/src/diag.mjs +74 -0
  49. package/src/docs.mjs +132 -0
  50. package/src/doctor.mjs +30 -0
  51. package/src/files.mjs +47 -0
  52. package/src/fix.mjs +40 -0
  53. package/src/gate.mjs +276 -0
  54. package/src/index.mjs +9 -0
  55. package/src/infer/engine.cjs +525 -0
  56. package/src/infer/facts.cjs +252 -0
  57. package/src/infer/hosts.cjs +114 -0
  58. package/src/infer/lsp.mjs +131 -0
  59. package/src/infer/session.cjs +105 -0
  60. package/src/infer/shadow.cjs +46 -0
  61. package/src/infer/worker.cjs +97 -0
  62. package/src/jsx-scan.mjs +86 -0
  63. package/src/load-config.mjs +303 -0
  64. package/src/map.mjs +110 -0
  65. package/src/mcp.mjs +195 -0
  66. package/src/meta-table.mjs +28 -0
  67. package/src/mutate.mjs +140 -0
  68. package/src/plugin-checks.mjs +89 -0
  69. package/src/plugins.mjs +172 -0
  70. package/src/project.mjs +219 -0
  71. package/src/props-checks.mjs +46 -0
  72. package/src/props-listener.mjs +38 -0
  73. package/src/react-checks.mjs +36 -0
  74. package/src/share-checks.mjs +74 -0
  75. package/src/site.mjs +19 -0
  76. package/src/slots.mjs +96 -0
  77. package/src/start.mjs +296 -0
  78. package/src/state-checks.mjs +213 -0
  79. package/src/style.mjs +320 -0
  80. package/src/surface-checks.mjs +218 -0
  81. package/src/tailwind.mjs +22 -0
  82. package/src/tasks.mjs +99 -0
  83. package/src/testdiff.mjs +157 -0
  84. package/src/tests.mjs +572 -0
  85. package/src/text-checks.mjs +172 -0
  86. package/src/tokens.mjs +24 -0
  87. package/src/trust.mjs +282 -0
  88. package/src/weft.mjs +66 -0
  89. package/src/workspace.mjs +105 -0
  90. package/ts-plugin/index.js +137 -0
  91. package/ts-plugin/package.json +1 -0
package/src/tests.mjs ADDED
@@ -0,0 +1,572 @@
1
+ // Static T-codes for test files (PLAN §7b, research 05 §5.1): tests that can't be junk.
2
+ //
3
+ // T001 no assertion T004 sleeps / real timers T007 unapproved snapshot
4
+ // T002 tautology T005 implementation queries T008 conditional or looped `expect`
5
+ // T003 asserts what it set T006 mocks weft or the unit T009 `.only` / `.skip`
6
+ // T013 a real socket
7
+ //
8
+ // Syntax only (TypeScript's parser, no type checker), like the project view. `@weft-js/core/test` adds a runtime T001 for
9
+ // assertions this pass can't see. A finding a test needs on purpose is kept with a written reason:
10
+ //
11
+ // // weft-allow T004, T005: hydration tests read the server DOM and wait for real timers
12
+ //
13
+ // in the file's leading comments covers the whole file; anywhere else it covers its own line and the next one.
14
+ // Without a reason it is ignored.
15
+
16
+ import { existsSync, readFileSync } from 'node:fs'
17
+ import { dirname, join, relative } from 'node:path'
18
+ import ts from 'typescript'
19
+ import { local } from './alias.mjs'
20
+ import { diagnostic, positions } from './diag.mjs'
21
+
22
+ export const testGlobs = ['**/*.{test,spec}.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']
23
+
24
+ const suiteRoots = new Set(['describe', 'suite', 'fdescribe', 'xdescribe'])
25
+ const testRoots = new Set(['test', 'it', 'fit', 'xit', 'xtest', ...suiteRoots])
26
+ const focusRoots = { fit: 'it', fdescribe: 'describe' }
27
+ const skipRoots = { xit: 'it', xtest: 'test', xdescribe: 'describe' }
28
+ const snapshotMatchers = new Set(['toMatchSnapshot', 'toMatchInlineSnapshot', 'toMatchFileSnapshot', 'toThrowErrorMatchingSnapshot', 'toThrowErrorMatchingInlineSnapshot'])
29
+ const sameValueMatchers = new Set(['toBe', 'toEqual', 'toStrictEqual'])
30
+ const domQueries = new Set(['querySelector', 'querySelectorAll', 'getElementsByClassName', 'getElementsByTagName', 'getElementById', 'closest'])
31
+ const loopCallbacks = new Set(['forEach', 'map', 'flatMap', 'filter', 'some', 'every', 'find', 'findIndex', 'reduce'])
32
+ const timerCalls = new Set(['setTimeout', 'setInterval', 'requestAnimationFrame', 'setImmediate'])
33
+ const exts = ['', '.ts', '.tsx', '.mts', '.js', '.jsx', '.mjs', '/index.ts', '/index.tsx', '/index.js']
34
+
35
+ const kindOf = file => (file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.jsx') ? ts.ScriptKind.JSX : /\.[mc]?ts$/.test(file) ? ts.ScriptKind.TS : ts.ScriptKind.JS)
36
+ export const parse = (text, file) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, kindOf(file))
37
+ const isFn = n => n && (ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isFunctionDeclaration(n))
38
+ export const norm = n => n.getText().replace(/\s+/g, ' ').replace(/;$/, '')
39
+ const unwrap = n => {
40
+ while (n && (ts.isParenthesizedExpression(n) || ts.isAsExpression(n) || ts.isNonNullExpression(n) || ts.isAwaitExpression(n))) n = n.expression
41
+ return n
42
+ }
43
+
44
+ function walk(node, visit) {
45
+ const go = n => {
46
+ if (visit(n) === false) return
47
+ ts.forEachChild(n, go)
48
+ }
49
+ go(node)
50
+ }
51
+
52
+ const contains = (node, pred) => {
53
+ let hit = false
54
+ walk(node, n => {
55
+ if (hit) return false
56
+ if (pred(n)) hit = true
57
+ })
58
+ return hit
59
+ }
60
+
61
+ /** The identifier at the root of `a.b(…).c`, or undefined. */
62
+ function rootName(expr) {
63
+ let n = expr
64
+ for (;;) {
65
+ n = unwrap(n)
66
+ if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n) || ts.isCallExpression(n)) n = n.expression
67
+ else break
68
+ }
69
+ return ts.isIdentifier(n) ? n.text : undefined
70
+ }
71
+
72
+ /** `1`, `'a'`, `null`, `[1, { a: 'b' }]`: a value with nothing behind it. */
73
+ function isLiteral(n) {
74
+ n = unwrap(n)
75
+ if (!n) return false
76
+ if (ts.isStringLiteral(n) || ts.isNumericLiteral(n) || ts.isBigIntLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n) || ts.isRegularExpressionLiteral(n)) return true
77
+ if ([ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(n.kind)) return true
78
+ if (ts.isIdentifier(n)) return n.text === 'undefined' || n.text === 'NaN' || n.text === 'Infinity'
79
+ if (ts.isPrefixUnaryExpression(n)) return isLiteral(n.operand)
80
+ if (ts.isArrayLiteralExpression(n)) return n.elements.every(isLiteral)
81
+ if (ts.isObjectLiteralExpression(n)) return n.properties.every(p => ts.isPropertyAssignment(p) && isLiteral(p.initializer))
82
+ return false
83
+ }
84
+
85
+ const hasCall = n => contains(n, x => ts.isCallExpression(x) || ts.isNewExpression(x) || ts.isAwaitExpression(x) || ts.isTaggedTemplateExpression(x))
86
+
87
+ /** `expect(x)`, `expect.soft(x)`, `t.expect(x)`: the call that takes the subject. */
88
+ export function isSubject(call) {
89
+ const c = call.expression
90
+ if (ts.isIdentifier(c)) return c.text === 'expect'
91
+ if (!ts.isPropertyAccessExpression(c)) return false
92
+ if (c.name.text === 'expect') return true
93
+ return ts.isIdentifier(c.expression) && c.expression.text === 'expect' && ['soft', 'poll'].includes(c.name.text)
94
+ }
95
+
96
+ /** For a subject call, the matcher it ends in: `expect(x).not.toBe(1)` → { name: 'toBe', call, not: true }. */
97
+ export function matcherOf(subject) {
98
+ let cur = subject
99
+ let not = false
100
+ const mods = []
101
+ for (;;) {
102
+ const p = cur.parent
103
+ if (p && ts.isPropertyAccessExpression(p) && p.expression === cur) {
104
+ const name = p.name.text
105
+ if (p.parent && ts.isCallExpression(p.parent) && p.parent.expression === p) return { name, node: p.name, call: p.parent, not, mods }
106
+ if (name === 'not') not = !not
107
+ mods.push(name)
108
+ cur = p
109
+ } else return null
110
+ }
111
+ }
112
+
113
+ /** Test and suite calls, with their modifiers: `test.only.each(table)('x', fn)`. */
114
+ export function testCall(call) {
115
+ // The outer call of a curried form (`test.each(t)(…)`) is the test; skip the inner one.
116
+ if (call.parent && ts.isCallExpression(call.parent) && call.parent.expression === call) return null
117
+ const mods = []
118
+ let n = call.expression
119
+ for (;;) {
120
+ if (ts.isPropertyAccessExpression(n)) {
121
+ mods.unshift({ name: n.name.text, node: n })
122
+ n = n.expression
123
+ } else if (ts.isCallExpression(n)) n = n.expression
124
+ else break
125
+ }
126
+ if (!ts.isIdentifier(n) || !testRoots.has(n.text)) return null
127
+ const root = n.text
128
+ const fn = [...call.arguments].reverse().find(a => ts.isArrowFunction(a) || ts.isFunctionExpression(a))
129
+ // `fit` with no test body is someone's function (@weft-js/core/text's), not a focused test
130
+ if (root === 'fit' && !fn) return null
131
+ return { root, rootNode: n, mods, fn, suite: suiteRoots.has(root), call }
132
+ }
133
+
134
+ /** Functions (by name) whose bodies assert, in one file: local helpers and, given `imported`, what they call. */
135
+ function assertingFunctions(sf, imported = new Set()) {
136
+ const bodies = new Map()
137
+ const add = (name, fn) => fn?.body && bodies.set(name, fn.body)
138
+ for (const st of sf.statements) {
139
+ const decl = ts.isExportDeclaration(st) ? null : st
140
+ if (decl && ts.isFunctionDeclaration(decl) && decl.name) add(decl.name.text, decl)
141
+ if (decl && ts.isVariableStatement(decl))
142
+ for (const d of decl.declarationList.declarations) if (ts.isIdentifier(d.name) && isFn(d.initializer)) add(d.name.text, d.initializer)
143
+ }
144
+ const asserting = new Set(imported)
145
+ for (let changed = true; changed; ) {
146
+ changed = false
147
+ for (const [name, body] of bodies) {
148
+ if (asserting.has(name) || !asserts(body, asserting)) continue
149
+ asserting.add(name)
150
+ changed = true
151
+ }
152
+ }
153
+ return asserting
154
+ }
155
+
156
+ /** Does this code assert? A matcher, `assert…`, `expect.assertions`, a helper named `expect…`, or a known asserting function. */
157
+ function asserts(node, asserting) {
158
+ return contains(node, n => {
159
+ if (!ts.isCallExpression(n)) return false
160
+ if (isSubject(n)) return Boolean(matcherOf(n))
161
+ const c = n.expression
162
+ if (ts.isPropertyAccessExpression(c) && ts.isIdentifier(c.expression) && c.expression.text === 'expect') return true // expect.assertions(n)
163
+ const name = ts.isIdentifier(c) ? c.text : ts.isPropertyAccessExpression(c) && ts.isIdentifier(c.expression) ? c.expression.text : undefined
164
+ return Boolean(name && (/^(expect|assert)([A-Z_]|$)/.test(name) || asserting.has(name)))
165
+ })
166
+ }
167
+
168
+ export function resolveImport(fromAbs, spec) {
169
+ const base = local(fromAbs, spec)
170
+ if (!base) return null
171
+ for (const e of exts) {
172
+ const p = base + e
173
+ if (existsSync(p) && !p.endsWith('/') && (e || /\.[cm]?[jt]sx?$/.test(p))) return p
174
+ }
175
+ return null
176
+ }
177
+
178
+ const moduleKey = abs => abs.replace(/(\/index)?\.[cm]?[jt]sx?$/, '')
179
+
180
+ /** Comment directives: `// weft-allow T004, T005: reason`. */
181
+ export function allowances(text, sf) {
182
+ const firstCode = sf.statements[0]?.getStart(sf) ?? text.length
183
+ const file = new Set()
184
+ const lines = new Map()
185
+ const pos = positions(text)
186
+ for (const m of text.matchAll(/\/\/[ \t]*weft-allow[ \t]+((?:T\d{3}[ \t,]*)+)(?::[ \t]*(\S.*))?$/gm)) {
187
+ if (!m[2]?.trim()) continue
188
+ const codes = m[1].match(/T\d{3}/g)
189
+ if (m.index < firstCode) for (const c of codes) file.add(c)
190
+ else {
191
+ const [line] = pos.lineCol(m.index)
192
+ for (const l of [line, line + 1]) for (const c of codes) lines.set(`${c}:${l}`, true)
193
+ }
194
+ }
195
+ return (code, line) => file.has(code) || lines.has(`${code}:${line}`)
196
+ }
197
+
198
+ /**
199
+ * T-codes for one test file.
200
+ * @param {string} text
201
+ * @param {string} rel path relative to the project root (for diagnostics)
202
+ * @param {{ abs?: string, root?: string }} [options] `abs` lets T001/T006 read the helpers and units the file imports
203
+ */
204
+ export function checkTestFile(text, rel, options = {}) {
205
+ const abs = options.abs ?? rel
206
+ const sf = parse(text, rel)
207
+ const pos = positions(text)
208
+ const allowed = allowances(text, sf)
209
+ const out = []
210
+ const seen = new Set()
211
+ const report = (code, node, why, extra = {}) => {
212
+ const start = node.getStart(sf)
213
+ const end = node.getEnd()
214
+ const span = pos.span(start, end)
215
+ const key = `${code}:${span[0]}:${span[1]}`
216
+ if (allowed(code, span[0]) || seen.has(key)) return
217
+ seen.add(key)
218
+ out.push(diagnostic({ code, file: rel, span, offset: [start, end], why, ...extra }))
219
+ }
220
+ const edit = (start, end, newText) => ({ file: rel, span: pos.span(start, end), offset: [start, end], text: newText })
221
+
222
+ // ---- what the file imports -----------------------------------------------------------------------------------
223
+ const imports = new Map() // local name → { spec, name, abs }
224
+ const namespaces = new Map() // `import * as x` → spec
225
+ for (const st of sf.statements) {
226
+ if (!ts.isImportDeclaration(st) || !st.importClause || !ts.isStringLiteral(st.moduleSpecifier)) continue
227
+ const spec = st.moduleSpecifier.text
228
+ const target = resolveImport(abs, spec)
229
+ if (st.importClause.name) imports.set(st.importClause.name.text, { spec, name: 'default', abs: target })
230
+ const nb = st.importClause.namedBindings
231
+ if (nb && ts.isNamespaceImport(nb)) namespaces.set(nb.name.text, { spec, abs: target })
232
+ if (nb && ts.isNamedImports(nb)) for (const el of nb.elements) imports.set(el.name.text, { spec, name: (el.propertyName ?? el.name).text, abs: target })
233
+ }
234
+ const helperAsserting = new Set()
235
+ const helperCache = new Map()
236
+ for (const [binding, imp] of imports) {
237
+ if (!imp.abs || /\.(test|spec)\./.test(imp.abs)) continue
238
+ if (!helperCache.has(imp.abs)) {
239
+ let names = new Set()
240
+ try {
241
+ const src = readFileSync(imp.abs, 'utf8')
242
+ names = assertingFunctions(parse(src, imp.abs))
243
+ } catch {}
244
+ helperCache.set(imp.abs, names)
245
+ }
246
+ if (helperCache.get(imp.abs).has(imp.name)) helperAsserting.add(binding)
247
+ }
248
+ const asserting = assertingFunctions(sf, helperAsserting)
249
+
250
+ // ---- what the file tests: components it renders, and the modules they come from --------------------------------
251
+ const renders = new Set()
252
+ walk(sf, n => {
253
+ if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'render' && n.arguments[0]) {
254
+ const first = unwrap(n.arguments[0])
255
+ if (ts.isIdentifier(first)) renders.add(first.text)
256
+ walk(first, j => {
257
+ if ((ts.isJsxOpeningElement(j) || ts.isJsxSelfClosingElement(j)) && ts.isIdentifier(j.tagName)) renders.add(j.tagName.text)
258
+ })
259
+ }
260
+ })
261
+ const isComponentTest = contains(sf, n => ts.isCallExpression(n) && ts.isIdentifier(n.expression) && ['render', 'mount', 'hydrate'].includes(n.expression.text))
262
+ const units = new Set()
263
+ const unitSpecs = new Set()
264
+ const specKey = spec => spec.replace(/(\/index)?\.[cm]?[jt]sx?$/, '')
265
+ for (const name of renders) {
266
+ const imp = imports.get(name)
267
+ if (imp?.abs) units.add(moduleKey(imp.abs))
268
+ if (imp && local(abs, imp.spec)) unitSpecs.add(specKey(imp.spec))
269
+ }
270
+ const own = resolveImport(abs, `./${relative(dirname(abs), abs).replace(/\.(test|spec)\.[cm]?[jt]sx?$/, '')}`)
271
+ if (own) units.add(moduleKey(own))
272
+ const isUnitSpec = spec => {
273
+ const t = resolveImport(abs, spec)
274
+ return t ? units.has(moduleKey(t)) : unitSpecs.has(specKey(spec))
275
+ }
276
+ const isWeftSpec = spec => spec === '@weft-js/core' || spec.startsWith('@weft-js/core/') || spec.startsWith('@weft-js/')
277
+ const fakeTimers = contains(sf, n => ts.isPropertyAccessExpression(n) && (n.name.text === 'useFakeTimers' || (n.name.text === 'install' && rootName(n) === 'clock')))
278
+
279
+ // ---- per test: T001, T003, T008, T009 ---------------------------------------------------------------------------
280
+ const tests = []
281
+ walk(sf, n => {
282
+ if (!ts.isCallExpression(n)) return
283
+ const t = testCall(n)
284
+ if (t) tests.push(t)
285
+ })
286
+
287
+ for (const t of tests) {
288
+ const focus = t.mods.find(m => m.name === 'only') ?? (focusRoots[t.root] && { name: 'only', node: t.rootNode })
289
+ const skip = t.mods.find(m => m.name === 'skip') ?? (skipRoots[t.root] && { name: 'skip', node: t.rootNode })
290
+ for (const m of [focus, skip].filter(Boolean)) {
291
+ const kind = t.suite ? 'suite' : 'test'
292
+ let fix
293
+ if (m.node === t.rootNode) {
294
+ const to = focusRoots[t.root] ?? skipRoots[t.root]
295
+ fix = { description: `Rename \`${t.root}\` to \`${to}\``, safe: m.name === 'only', edits: [edit(m.node.getStart(sf), m.node.getEnd(), to)] }
296
+ report('T009', m.node, `\`${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'}.`, { fix })
297
+ } else {
298
+ const dot = m.node.expression.getEnd()
299
+ fix = { description: `Remove \`.${m.name}\``, safe: m.name === 'only', edits: [edit(dot, m.node.getEnd(), '')] }
300
+ report('T009', m.node.name, m.name === 'only'
301
+ ? `\`.only\` focuses this ${kind}: every other test in the run is silently skipped.`
302
+ : `\`.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\`.`, { fix })
303
+ }
304
+ }
305
+ if (t.suite || !t.fn || t.mods.some(m => ['todo', 'skip'].includes(m.name)) || skipRoots[t.root]) continue
306
+ const body = t.fn.body
307
+
308
+ const noAssertion = !asserts(body, asserting)
309
+ if (noAssertion) {
310
+ const title = t.call.arguments[0] && ts.isStringLiteralLike(t.call.arguments[0]) ? ` "${t.call.arguments[0].text}"` : ''
311
+ report('T001', t.call.expression, `Test${title} makes no assertion: it passes as long as nothing throws.`)
312
+ }
313
+
314
+ // A looped `expect` is fine next to one that always runs; a conditional one never is.
315
+ const looped = []
316
+ let always = false
317
+ walk(body, n => {
318
+ if (!ts.isCallExpression(n) || !isSubject(n)) return
319
+ const m = matcherOf(n)
320
+ if (!m) {
321
+ if (!noAssertion) report('T001', n, `\`${norm(n)}\` has no matcher, so it asserts nothing.`, { help: 'Finish it: `expect(x).toBe(…)`, `.toHaveTextContent(…)`, …' })
322
+ return
323
+ }
324
+ let why
325
+ for (let a = n.parent; a && a !== t.fn && !why; a = a.parent) why = conditional(a, n)
326
+ if (!why) always = true
327
+ else if (why.loop) looped.push([m.call, why.text])
328
+ else report('T008', m.call, `This \`expect\` only runs ${why.text}, so the test can pass without checking anything.`)
329
+ })
330
+ if (!always) for (const [call, how] of looped) report('T008', call, `Every \`expect\` in this test runs ${how}, so the test can pass without checking anything.`)
331
+
332
+ if (ts.isBlock(body)) assertsWhatItSet(body.statements, report)
333
+ }
334
+
335
+ // ---- whole file: T002, T004, T005, T006, T007 ---------------------------------------------------------------------
336
+ const renderResults = new Set()
337
+ walk(sf, n => {
338
+ if (ts.isVariableDeclaration(n) && n.initializer && ts.isCallExpression(unwrap(n.initializer)) && rootName(unwrap(n.initializer).expression) === 'render') {
339
+ if (ts.isIdentifier(n.name)) renderResults.add(n.name.text)
340
+ if (ts.isObjectBindingPattern(n.name))
341
+ for (const el of n.name.elements) if ((el.propertyName ?? el.name).getText() === 'container') report('T005', el, '`container` reaches into the DOM tree the user never sees.')
342
+ }
343
+ })
344
+
345
+ walk(sf, n => {
346
+ if (ts.isCallExpression(n)) {
347
+ const c = n.expression
348
+ const name = ts.isIdentifier(c) ? c.text : ts.isPropertyAccessExpression(c) ? c.name.text : undefined
349
+
350
+ if (isSubject(n)) {
351
+ const m = matcherOf(n)
352
+ const subject = n.arguments[0]
353
+ if (m && subject) {
354
+ if (isLiteral(subject)) report('T002', n, `\`${norm(n)}\` checks a literal, so it can never fail.`)
355
+ else if (sameValueMatchers.has(m.name) && !m.not && m.call.arguments[0] && norm(m.call.arguments[0]) === norm(subject) && !hasCall(subject))
356
+ report('T002', m.call, `\`${norm(m.call)}\` compares \`${norm(subject)}\` with itself, so it can never fail.`)
357
+ else if (ts.isBinaryExpression(unwrap(subject)) && isComparison(unwrap(subject)) && norm(unwrap(subject).left) === norm(unwrap(subject).right) && !hasCall(subject))
358
+ report('T002', n, `\`${norm(subject)}\` compares a value with itself, so it can never fail.`)
359
+ if (snapshotMatchers.has(m.name))
360
+ report('T007', m.node, `\`${m.name}\` records whatever the code does today and passes against it: it pins output, not behavior.`)
361
+ if (m.name === 'toHaveClass') report('T005', m.node, '`toHaveClass` asserts on styling classes, not on what the user sees.')
362
+ if (contains(subject, x => ts.isPropertyAccessExpression(x) && ['className', 'classList', 'innerHTML', 'outerHTML'].includes(x.name.text))) {
363
+ const prop = []
364
+ walk(subject, x => { if (ts.isPropertyAccessExpression(x) && ['className', 'classList', 'innerHTML', 'outerHTML'].includes(x.name.text)) prop.push(x.name.text) })
365
+ report('T005', subject, `Asserting on \`${prop[0]}\` pins markup and classes, not what the user sees.`)
366
+ }
367
+ if (isComponentTest && readsInternalState(subject, imports)) report('T005', subject, `\`${norm(subject)}\` reads the component's state directly instead of what it renders.`)
368
+ }
369
+ }
370
+
371
+ if (name && domQueries.has(name) && ts.isPropertyAccessExpression(c)) report('T005', c.name, `\`${name}\` queries by CSS structure, which users never see.`)
372
+ if (name && /^(get|query|find)(All)?ByTestId$/.test(name)) report('T005', ts.isPropertyAccessExpression(c) ? c.name : c, `\`${name}\` finds the element by a test id the user never sees.`)
373
+
374
+ // T004: sleeps, real timers and polling.
375
+ if (name === 'waitFor' || name === 'waitForElementToBeRemoved')
376
+ report('T004', c, `\`${name}\` polls on real timers and guesses how long the update takes.`, { help: '`await user.click(…)` and `await flush()` settle every update; there is nothing to wait for' })
377
+ if (ts.isIdentifier(c) && ['sleep', 'delay'].includes(c.text) && ts.isAwaitExpression(n.parent))
378
+ report('T004', n, `\`${norm(n)}\` sleeps on the real clock: slow, and flaky when the machine is busy.`)
379
+ if (name && timerCalls.has(name) && (ts.isIdentifier(c) || ['window', 'globalThis', 'global'].includes(rootName(c)))) {
380
+ const sleep = sleepPromise(n)
381
+ if (sleep) {
382
+ const delay = n.arguments[1]
383
+ const zero = !delay || (ts.isNumericLiteral(delay) && Number(delay.text) === 0)
384
+ const fix = zero ? flushFix(sf, sleep, edit) : undefined
385
+ report('T004', sleep, `\`${norm(sleep)}\` sleeps on the real clock: slow, and flaky when the machine is busy.`, {
386
+ help: zero ? 'Use `await flush()`: it settles every pending update' : `Install the fake clock (\`clock.install()\`) and use \`await clock.advance(${delay.getText()})\``,
387
+ fix,
388
+ })
389
+ } else if (!fakeTimers && !insideFakeApi(n))
390
+ report('T004', c, `\`${name}\` runs on the real clock in a test.`, { help: 'Call `clock.install()` from @weft-js/core/test first and drive time with `await clock.advance(ms)`' })
391
+ }
392
+
393
+ // T006: mocking weft or the unit under test.
394
+ if (name && ['mock', 'doMock'].includes(name) && ['vi', 'jest'].includes(rootName(c)) && n.arguments[0] && ts.isStringLiteralLike(n.arguments[0])) {
395
+ const spec = n.arguments[0].text
396
+ if (isWeftSpec(spec)) report('T006', n, `Mocking \`${spec}\` replaces the framework the test is supposed to exercise.`)
397
+ else if (isUnitSpec(spec)) report('T006', n, `Mocking \`${spec}\` replaces the unit under test, so the test checks the mock.`)
398
+ }
399
+ if (name === 'spyOn' && ['vi', 'jest'].includes(rootName(c)) && n.arguments[0] && ts.isIdentifier(n.arguments[0])) {
400
+ const ns = namespaces.get(n.arguments[0].text)
401
+ if (ns && isWeftSpec(ns.spec)) report('T006', n, `Spying on \`${ns.spec}\` replaces the framework the test is supposed to exercise.`)
402
+ else if (ns && isUnitSpec(ns.spec)) report('T006', n, `Spying on \`${ns.spec}\` replaces the unit under test, so the test checks the mock.`)
403
+ }
404
+ }
405
+
406
+ // T013: a real socket in the fast lane.
407
+ if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && ['WebSocket', 'EventSource'].includes(n.expression.text))
408
+ report('T013', n, `\`new ${n.expression.text}\` opens a real socket in a fast test.`, { 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()`" })
409
+
410
+ if (ts.isPropertyAccessExpression(n) && n.name.text === 'container' && !(ts.isCallExpression(n.parent) && n.parent.expression === n)) {
411
+ const obj = unwrap(n.expression)
412
+ const fromRender = (ts.isCallExpression(obj) && rootName(obj.expression) === 'render') || (ts.isIdentifier(obj) && renderResults.has(obj.text))
413
+ if (fromRender) report('T005', n, '`container` reaches into the DOM tree the user never sees.')
414
+ }
415
+ })
416
+
417
+ return out
418
+ }
419
+
420
+ const isComparison = b => [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken].includes(b.operatorToken.kind)
421
+
422
+ /** Why `node`, under ancestor `a`, only runs sometimes (`loop`: maybe zero times); undefined when it always runs. */
423
+ function conditional(a, node) {
424
+ const within = x => x && node.getStart() >= x.getStart() && node.getEnd() <= x.getEnd()
425
+ const when = text => ({ text })
426
+ const loop = text => ({ text, loop: true })
427
+ if (ts.isIfStatement(a) && !within(a.expression)) return when('when the `if` matches')
428
+ if (ts.isConditionalExpression(a) && !within(a.condition)) return when('on one side of a `?:`')
429
+ if (ts.isBinaryExpression(a) && [ts.SyntaxKind.AmpersandAmpersandToken, ts.SyntaxKind.BarBarToken, ts.SyntaxKind.QuestionQuestionToken].includes(a.operatorToken.kind) && within(a.right)) return when(`after a \`${a.operatorToken.getText()}\``)
430
+ if (ts.isCaseClause(a) || ts.isDefaultClause(a)) return when('in one `switch` case')
431
+ if (ts.isCatchClause(a)) return when('when something throws')
432
+ if ((ts.isForStatement(a) || ts.isForOfStatement(a) || ts.isForInStatement(a) || ts.isWhileStatement(a) || ts.isDoStatement(a)) && within(a.statement)) return loop('inside a loop, and zero iterations check nothing')
433
+ if (isFn(a) && ts.isCallExpression(a.parent) && ts.isPropertyAccessExpression(a.parent.expression) && loopCallbacks.has(a.parent.expression.name.text) && a.parent.arguments.includes(a))
434
+ return loop(`inside \`.${a.parent.expression.name.text}\`, and an empty list checks nothing`)
435
+ return undefined
436
+ }
437
+
438
+ /** `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.) */
439
+ function readsInternalState(subject, imports) {
440
+ const s = unwrap(subject)
441
+ if (!ts.isPropertyAccessExpression(s) || s.name.text !== 'value') return false
442
+ let n = s.expression
443
+ for (;;) {
444
+ n = unwrap(n)
445
+ if (ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) n = n.expression
446
+ else break
447
+ }
448
+ return ts.isIdentifier(n) && Boolean(imports.get(n.text)?.abs)
449
+ }
450
+
451
+ /** `new Promise(r => setTimeout(r, ms))`: the timer call's enclosing sleep, or undefined. */
452
+ function sleepPromise(timer) {
453
+ const cb = timer.arguments[0]
454
+ let exec = timer.parent
455
+ while (exec && (ts.isExpressionStatement(exec) || ts.isBlock(exec))) exec = exec.parent
456
+ if (!isFn(exec) || !ts.isNewExpression(exec.parent) || exec.parent.expression.getText() !== 'Promise') return undefined
457
+ const resolveName = exec.parameters[0]?.name.getText()
458
+ const callsResolve = cb && (cb.getText() === resolveName || (isFn(cb) && contains(cb.body, x => ts.isCallExpression(x) && x.expression.getText() === resolveName)))
459
+ if (!callsResolve) return undefined
460
+ // A fake API's latency resolves with data (`ok(user)`); a sleep resolves with nothing.
461
+ if (isFn(cb) && contains(cb.body, x => ts.isCallExpression(x) && x.expression.getText() === resolveName && x.arguments.length > 0)) return undefined
462
+ return exec.parent
463
+ }
464
+
465
+ /** Timers inside a function that a fake API returns (`load: id => new Promise(ok => setTimeout(() => ok(x), ms))`). */
466
+ function insideFakeApi(timer) {
467
+ for (let n = timer.parent; n; n = n.parent) {
468
+ if (ts.isNewExpression(n) && n.expression.getText() === 'Promise') return true
469
+ }
470
+ return false
471
+ }
472
+
473
+ /** Replace a zero-delay sleep with `flush()`, importing it from @weft-js/core/test when the file doesn't. */
474
+ function flushFix(sf, sleep, edit) {
475
+ const edits = [edit(sleep.getStart(sf), sleep.getEnd(), 'flush()')]
476
+ const hasFlush = sf.statements.some(st => ts.isImportDeclaration(st) && st.importClause?.namedBindings && ts.isNamedImports(st.importClause.namedBindings) && st.importClause.namedBindings.elements.some(e => e.name.text === 'flush'))
477
+ if (!hasFlush) {
478
+ const weftTest = sf.statements.find(st => ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier) && st.moduleSpecifier.text === '@weft-js/core/test' && st.importClause?.namedBindings && ts.isNamedImports(st.importClause.namedBindings))
479
+ if (weftTest) {
480
+ const el = weftTest.importClause.namedBindings.elements
481
+ edits.push(edit(el[el.length - 1].getEnd(), el[el.length - 1].getEnd(), ', flush'))
482
+ } else {
483
+ const last = [...sf.statements].reverse().find(st => ts.isImportDeclaration(st))
484
+ const at = last ? last.getEnd() : 0
485
+ edits.push(edit(at, at, `${last ? '\n' : ''}import { flush } from '@weft-js/core/test'${last ? '' : '\n'}`))
486
+ }
487
+ }
488
+ return { description: 'Replace the sleep with `flush()`', safe: false, edits }
489
+ }
490
+
491
+ /**
492
+ * T003 in one test body: `n.value = 5; expect(n.value).toBe(5)`, or `const s = state(3); expect(s.value).toBe(3)`.
493
+ * Any call between the write and the assertion (a click, a render, a fake push) may route through the app, so it ends
494
+ * the check; `flush()` doesn't.
495
+ */
496
+ function assertsWhatItSet(statements, report) {
497
+ const writes = new Map()
498
+ const boxes = new Set() // `const n = state(…)`: a plain box, so a write reads straight back
499
+ const locals = new Set()
500
+ const isFlush = st => {
501
+ const e = ts.isExpressionStatement(st) ? unwrap(st.expression) : null
502
+ return e && ts.isCallExpression(e) && e.expression.getText() === 'flush'
503
+ }
504
+ for (const st of statements) {
505
+ if (ts.isVariableStatement(st)) {
506
+ for (const d of st.declarationList.declarations) {
507
+ if (!ts.isIdentifier(d.name) || !d.initializer) continue
508
+ const init = unwrap(d.initializer)
509
+ locals.add(d.name.text)
510
+ if (ts.isCallExpression(init) && init.expression.getText() === 'state') boxes.add(d.name.text)
511
+ if (isLiteral(init)) writes.set(d.name.text, init)
512
+ else if (ts.isCallExpression(init) && init.expression.getText() === 'state' && init.arguments[0] && isLiteral(init.arguments[0])) writes.set(`${d.name.text}.value`, init.arguments[0])
513
+ else if (hasCall(init)) writes.clear()
514
+ }
515
+ continue
516
+ }
517
+ if (isFlush(st)) continue
518
+ const e = ts.isExpressionStatement(st) ? unwrap(st.expression) : null
519
+ if (e && ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.EqualsToken && !hasCall(e.left)) {
520
+ const target = norm(e.left)
521
+ const plain = (ts.isIdentifier(e.left) && locals.has(target)) || (ts.isPropertyAccessExpression(e.left) && e.left.name.text === 'value' && boxes.has(norm(e.left.expression)))
522
+ if (isLiteral(e.right) && plain) writes.set(target, e.right)
523
+ else writes.delete(target)
524
+ if (hasCall(e.right)) writes.clear()
525
+ continue
526
+ }
527
+ const subject = e && findSubject(e)
528
+ if (subject) {
529
+ const m = matcherOf(subject)
530
+ const written = subject.arguments[0] && writes.get(norm(subject.arguments[0]))
531
+ if (m && !m.not && sameValueMatchers.has(m.name) && written && m.call.arguments[0] && norm(m.call.arguments[0]) === norm(written))
532
+ report('T003', m.call, `The test set \`${norm(subject.arguments[0])}\` to \`${norm(written)}\` itself and asserts it back: nothing between went through the UI or the graph.`)
533
+ continue
534
+ }
535
+ writes.clear()
536
+ }
537
+ }
538
+
539
+ /** The subject call of a statement that is one assertion chain (`expect(a).toBe(b)`), else undefined. */
540
+ function findSubject(e) {
541
+ let n = unwrap(e)
542
+ for (;;) {
543
+ if (ts.isCallExpression(n) && isSubject(n)) return n
544
+ if (ts.isCallExpression(n) || ts.isPropertyAccessExpression(n)) n = unwrap(n.expression)
545
+ else return undefined
546
+ }
547
+ }
548
+
549
+ /**
550
+ * T-codes for the project's test files.
551
+ * @param {string} root absolute project root
552
+ * @param {string[]} rels test files, relative to `root`
553
+ */
554
+ export function checkTests(root, rels) {
555
+ return rels.flatMap(rel => {
556
+ const abs = join(root, rel)
557
+ return checkTestFile(readFileSync(abs, 'utf8'), rel, { abs })
558
+ })
559
+ }
560
+
561
+ export const isTestFile = rel => /\.(test|spec)\.[cm]?[jt]sx?$/.test(rel)
562
+
563
+ /** Which T-codes this pass reports: each catalog example must produce its own code (the T side of `probe()`). */
564
+ export function probeTests(catalog) {
565
+ const out = {}
566
+ for (const e of catalog) {
567
+ if (!/^T\d{3}$/.test(e.code) || e.by !== 'cli') continue // T012 is @weft-js/core/test's runtime check
568
+ const hit = e.example && checkTestFile(e.example, 'probe.test.tsx').find(d => d.code === e.code)
569
+ out[e.code] = { implemented: Boolean(hit), autofix: hit?.fix?.safe === true, suggestion: Boolean(hit?.fix?.edits) && hit.fix.safe !== true }
570
+ }
571
+ return out
572
+ }